Merge branch 'iipbs/as/dev'
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.3.8",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
CklstMngSaveRequest,
|
||||
CklstMngSearchParams,
|
||||
CklstMngService,
|
||||
CklstTreeNode,
|
||||
CklstTreeSearchParams,
|
||||
} from "../model/cklst-mng-interface";
|
||||
|
||||
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
|
||||
@@ -21,6 +23,13 @@ async function findById(cklstSn: number): Promise<CklstMng> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
|
||||
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/tree`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstMngSaveRequest): Promise<number> {
|
||||
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
|
||||
return response.data;
|
||||
@@ -42,6 +51,7 @@ async function remove(cklstSn: number): Promise<number> {
|
||||
export const cklstMngService = {
|
||||
findPage,
|
||||
findById,
|
||||
findTree,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
|
||||
@@ -33,6 +33,8 @@ export type {
|
||||
CklstMngSaveRequest,
|
||||
CklstMngSearchParams,
|
||||
CklstMngService,
|
||||
CklstTreeNode,
|
||||
CklstTreeSearchParams,
|
||||
} from "./model/cklst-mng-interface";
|
||||
export type {
|
||||
CklstRvw,
|
||||
|
||||
@@ -13,6 +13,24 @@ export interface CklstMng {
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
export interface CklstTreeNode {
|
||||
cklstSn: number;
|
||||
cklstNm: string;
|
||||
cklstArtclSn: number;
|
||||
cklstArtclNm: string;
|
||||
cklstRvwSn: number;
|
||||
upCklstRvwSn: number | null;
|
||||
treeLevel: number;
|
||||
rvwCn: string;
|
||||
bchckUseYn: "Y" | "N";
|
||||
sortSeq: number;
|
||||
}
|
||||
|
||||
export interface CklstTreeSearchParams {
|
||||
docSeComCd?: string;
|
||||
taskSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstMngSearchParams extends PageableParams {
|
||||
cklstNm?: string;
|
||||
taskSeComCd?: string;
|
||||
@@ -30,6 +48,7 @@ export interface CklstMngSaveRequest {
|
||||
export interface CklstMngService {
|
||||
findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>>;
|
||||
findById(cklstSn: number): Promise<CklstMng>;
|
||||
findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]>;
|
||||
create(body: CklstMngSaveRequest): Promise<number>;
|
||||
update(cklstSn: number, body: CklstMngSaveRequest): Promise<number>;
|
||||
remove(cklstSn: number): Promise<number>;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface";
|
||||
|
||||
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
|
||||
const response = await client.get<CnstpSeMngTreeDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cnstpService = {
|
||||
findTree,
|
||||
} satisfies CnstpService;
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CnstpSeMngTreeDto, CnstpService } from "./model/cnstp-interface";
|
||||
export { cnstpService } from "./api/cnstp-service";
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 공종구분 트리 노드 DTO */
|
||||
export interface CnstpSeMngTreeDto {
|
||||
/** 들여쓰기 포함 표시명 */
|
||||
indCnstpSeCdNm: string;
|
||||
/** 공종구분코드 */
|
||||
cnstpSeCd: string;
|
||||
/** 공종구분코드명 */
|
||||
cnstpSeCdNm: string;
|
||||
/** 상위 공종구분코드 (null = 최상위) */
|
||||
upCnstpCd: string | null;
|
||||
/** 사용여부 */
|
||||
useYn: string;
|
||||
/** 트리 레벨 */
|
||||
treeLevel: number;
|
||||
/** 최상위~현재 노드까지 "|" 구분 경로 */
|
||||
treePath: string;
|
||||
}
|
||||
|
||||
/** 공종구분 서비스 인터페이스 */
|
||||
export interface CnstpService {
|
||||
/** 공종구분 트리 전체 조회 */
|
||||
findTree(): Promise<CnstpSeMngTreeDto[]>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type {
|
||||
InstlSncnApplySaveRequestDto,
|
||||
InstlSncnMng,
|
||||
InstlSncnMngSearchParams,
|
||||
InstlSncnMngService,
|
||||
@@ -19,6 +20,14 @@ export class InstlSncnApiService implements InstlSncnMngService {
|
||||
const response = await client.get<InstlSncnMng>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const InstlSncnApi = new InstlSncnApiService();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export { InstlSncnApi } from "./api/instl-sncn-service";
|
||||
export type {
|
||||
InstlSncnMng,
|
||||
InstlSncnMngSearchParams,
|
||||
SpringPage,
|
||||
export {
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
type InstlSncnApplySaveRequestDto,
|
||||
type InstlSncnBizCstSaveRequestDto,
|
||||
type InstlSncnBzScaleSaveDto,
|
||||
type InstlSncnCklstResultSaveDto,
|
||||
type InstlSncnMng,
|
||||
type InstlSncnMngSearchParams,
|
||||
type SpringPage,
|
||||
} from "./model/instl-sncn-interface";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
|
||||
|
||||
export interface SpringPage<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
@@ -52,7 +54,57 @@ export interface InstlSncnMng {
|
||||
rgtrNm: string;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBzScaleSaveDto {
|
||||
/** 공종구분코드 */
|
||||
cnstpSeCd: string;
|
||||
/** 기본계획(A) */
|
||||
bscPl: number;
|
||||
/** 예산반영(B) */
|
||||
budgetReflct: number;
|
||||
/** 금회설계시(C) */
|
||||
thtiDsgn: number;
|
||||
/** 증감(C-B) */
|
||||
diff: number;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBizCstSaveRequestDto {
|
||||
/** 회계구분코드 */
|
||||
acntgSeCd: string;
|
||||
/** 기본계획금액 */
|
||||
bscPlanAmt: number;
|
||||
/** 예산반영금액 */
|
||||
bgtRfltAmt: number;
|
||||
/** 금회설계금액 */
|
||||
tstmDesignAmt: number;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnCklstResultSaveDto {
|
||||
cklstSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstRvwSn: number;
|
||||
selected: "Y" | "N" | null;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnEsntlAtchFileSaveDto {
|
||||
esntlAtchFileId: string;
|
||||
atchFileGroupId: string;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 통합 저장 요청 DTO */
|
||||
export interface InstlSncnApplySaveRequestDto {
|
||||
instlSncnMng: InstlSncnMng;
|
||||
bzScaleList: InstlSncnBzScaleSaveDto[];
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[];
|
||||
cklstResultList: InstlSncnCklstResultSaveDto[];
|
||||
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
|
||||
}
|
||||
|
||||
export interface InstlSncnMngService {
|
||||
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
|
||||
getById: (id: string) => Promise<InstlSncnMng>;
|
||||
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,17 @@ export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
|
||||
const visibleRows = getVisibleRows(rows);
|
||||
|
||||
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
|
||||
if (receiverCount === 0) {
|
||||
return "접수자는 반드시 등록해야 합니다.";
|
||||
}
|
||||
if (receiverCount > 1) {
|
||||
return "접수자는 한 명만 등록할 수 있습니다.";
|
||||
}
|
||||
|
||||
const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length;
|
||||
if (finalCount === 0) {
|
||||
return "최종승인자는 반드시 등록해야 합니다.";
|
||||
}
|
||||
if (finalCount > 1) {
|
||||
return "최종승인자는 한 명만 등록할 수 있습니다.";
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
};
|
||||
|
||||
const handleRowDelete = async (row: ElaprAplnRow) => {
|
||||
if (!(await confirm("선택한 결재선을 삭제하시겠습니까?"))) return;
|
||||
markDeleted(row.rowKey);
|
||||
};
|
||||
|
||||
@@ -129,6 +128,8 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirm("결재선을 저장하시겠습니까?"))) return;
|
||||
|
||||
await saveMultiMutation.mutateAsync({ body: payload, elaprTmpltId });
|
||||
alert("결재선이 저장되었습니다.");
|
||||
await aplnQuery.refetch();
|
||||
@@ -158,12 +159,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
isDisabled ||
|
||||
isEditingLocked ||
|
||||
!canSave ||
|
||||
saveMultiMutation.isPending
|
||||
}
|
||||
disabled={isDisabled || isEditingLocked || !canSave || saveMultiMutation.isPending}
|
||||
onClick={handleSaveAll}
|
||||
>
|
||||
결재선 저장
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -14,7 +17,7 @@ import {
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { buildRvwDisplayRows, formatRvwCnLvNo, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
|
||||
import { buildRvwDisplayRows, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
|
||||
import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
|
||||
|
||||
interface CklstPreviewDialogProps {
|
||||
@@ -49,36 +52,48 @@ function CklstPreviewDialog({
|
||||
<p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<colgroup>
|
||||
<col width="25%" />
|
||||
<col width="50%" />
|
||||
<col width="25%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>점검항목</TableHead>
|
||||
<TableHead>주요 검토내용</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead align="left">점검항목</TableHead>
|
||||
<TableHead align="left">주요 검토내용</TableHead>
|
||||
<TableHead>검토 여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => {
|
||||
const branchPrefix = formatTreeBranchPrefix(depth);
|
||||
|
||||
if (row.useYn === "N") return null;
|
||||
|
||||
return (
|
||||
<TableRow key={row.rowKey}>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="font-mono text-muted-foreground">{branchPrefix}</span>
|
||||
) : null}
|
||||
<span>{getArtclLabel(row.cklstArtclSn)}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{row.rvwCn ?? "—"}</TableCell>
|
||||
<TableCell align="center">
|
||||
{row.useYn === "Y" ? "예" : row.useYn === "N" ? "아니오" : "—"}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{formatRvwCnLvNo(row.rvwCnLvNo)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key={row.rowKey}>
|
||||
<TableCell align="left">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<span>{getArtclLabel(row.cklstArtclSn)}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{row.rvwCn ?? "—"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup className="flex items-center justify-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
|
||||
@@ -155,17 +155,31 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
);
|
||||
|
||||
const handleDelete = async (row: CklstRvwRow) => {
|
||||
if (!(await confirm("선택한 항목을 삭제하시겠습니까?"))) return;
|
||||
if (
|
||||
!(await confirm(
|
||||
"선택한 항목을 삭제하시겠습니까?\n삭제한 항목은 되돌릴 수 없으며, 하위 항목까지 모두 삭제됩니다.",
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
if (row.isNew) {
|
||||
removeRowLocal(row.rowKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.cklstRvwSn == null) return;
|
||||
const children = rows.filter((r) => r.upCklstRvwSn === row.cklstRvwSn);
|
||||
if (children.length > 0) {
|
||||
for (const child of children) {
|
||||
await deleteMutation.mutateAsync({
|
||||
cklstRvwSn: child.cklstRvwSn!,
|
||||
cklstArtclSn: child.cklstArtclSn,
|
||||
cklstSn: child.cklstSn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await deleteMutation.mutateAsync({
|
||||
cklstRvwSn: row.cklstRvwSn,
|
||||
cklstRvwSn: row.cklstRvwSn!,
|
||||
cklstArtclSn: row.cklstArtclSn,
|
||||
cklstSn: row.cklstSn,
|
||||
});
|
||||
@@ -184,6 +198,8 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
|
||||
|
||||
if (row.isNew) {
|
||||
await createMutation.mutateAsync({
|
||||
cklstSn: row.cklstSn,
|
||||
@@ -233,9 +249,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
미리보기
|
||||
</Button>
|
||||
<Button type="button" color="white" disabled={isDisabled} onClick={addRootRow}>
|
||||
추가
|
||||
+ 체크리스트 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={isDisabled || isSaving} onClick={handleSave}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={getDirtyRows().length === 0 || isDisabled || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,7 +268,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
</div>
|
||||
) : isDisabled ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
상단에서 점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다.
|
||||
점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다.
|
||||
</p>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { canAddChildRow, formatRvwCnLvNo, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
|
||||
import {
|
||||
canAddChildRow,
|
||||
formatRvwCnLvNo,
|
||||
formatTreeBranchPrefix,
|
||||
} from "../model/build-rvw-display-rows";
|
||||
import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
|
||||
|
||||
interface CklstRvwRowProps {
|
||||
@@ -44,11 +48,12 @@ function CklstRvwRowComponent({
|
||||
<TableCell>
|
||||
<div className="flex min-w-[12rem] items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="shrink-0 font-mono text-muted-foreground">{branchPrefix}</span>
|
||||
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<Select
|
||||
value={row.cklstArtclSn ? String(row.cklstArtclSn) : ""}
|
||||
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
|
||||
disabled={!row.isNew}
|
||||
>
|
||||
<SelectTrigger className="w-full flex-1">
|
||||
<SelectValue placeholder="점검항목 선택" />
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import { InstlSncnApi, type InstlSncnMngSearchParams } from "@/entities/instl-sncn";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
InstlSncnApi,
|
||||
type InstlSncnMngSearchParams,
|
||||
} from "@/entities/instl-sncn";
|
||||
|
||||
import "./cnfm-page.css";
|
||||
|
||||
@@ -65,6 +70,17 @@ function CnfmListPage() {
|
||||
});
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
|
||||
const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState("");
|
||||
|
||||
const { data: fnrsCodeList = [] } = useCodeList("INSTL");
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskDialogOpen) return;
|
||||
if (selectedTaskSeComCd) return;
|
||||
if (fnrsCodeList.length === 0) return;
|
||||
setSelectedTaskSeComCd(fnrsCodeList[0].lwpoCd);
|
||||
}, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["instl-sncn-list", search, pageIndex, pageSize],
|
||||
@@ -91,6 +107,26 @@ function CnfmListPage() {
|
||||
setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd });
|
||||
};
|
||||
|
||||
const handleOpenTaskDialog = () => {
|
||||
setSelectedTaskSeComCd((prev) => prev || fnrsCodeList[0]?.lwpoCd || "");
|
||||
setTaskDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmTaskDialog = () => {
|
||||
const selected = fnrsCodeList.find((item) => item.lwpoCd === selectedTaskSeComCd);
|
||||
if (!selected) return;
|
||||
|
||||
sessionStorage.setItem(
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
JSON.stringify({
|
||||
taskSeComCd: selected.lwpoCd,
|
||||
taskSeComCdNm: selected.lwpoCdNm,
|
||||
}),
|
||||
);
|
||||
setTaskDialogOpen(false);
|
||||
router.push("/instl-sncn/register");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cnfm-page-wrap">
|
||||
{/* 검색 영역 */}
|
||||
@@ -160,7 +196,7 @@ function CnfmListPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="cnfm-btn-register"
|
||||
onClick={() => router.push("/instl-sncn/register")}
|
||||
onClick={handleOpenTaskDialog}
|
||||
>
|
||||
✎ 신청
|
||||
</button>
|
||||
@@ -311,6 +347,58 @@ function CnfmListPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{taskDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="w-[72rem] max-w-[95vw] overflow-hidden rounded-md border bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<h4 className="text-[2rem] font-bold text-gray-700">업무구분 선택</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-gray-300 px-4 py-1 text-[1.2rem] text-gray-600 hover:bg-gray-50"
|
||||
onClick={() => setTaskDialogOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<div className="flex flex-wrap items-center gap-8 text-[1.5rem]">
|
||||
{fnrsCodeList.map((item) => (
|
||||
<label key={item.lwpoCd} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="taskSeComCdSelect"
|
||||
value={item.lwpoCd}
|
||||
checked={selectedTaskSeComCd === item.lwpoCd}
|
||||
onChange={() => setSelectedTaskSeComCd(item.lwpoCd)}
|
||||
/>
|
||||
{item.lwpoCdNm}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-gray-300 px-5 py-2 text-[1.3rem] text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => setTaskDialogOpen(false)}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-500 px-5 py-2 text-[1.3rem] font-semibold text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
onClick={handleConfirmTaskDialog}
|
||||
disabled={!selectedTaskSeComCd}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type HttpResponse,Upload } from "tus-js-client";
|
||||
|
||||
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { getCookie, setCookie } from "@/shared/lib";
|
||||
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
|
||||
import { acntgService } from "@/entities/acntg";
|
||||
import type { CklstTreeNode } from "@/entities/cklst";
|
||||
import { cklstMngService } from "@/entities/cklst";
|
||||
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
|
||||
import { cnstpService } from "@/entities/cnstp";
|
||||
import {
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
InstlSncnApi,
|
||||
type InstlSncnApplySaveRequestDto,
|
||||
type InstlSncnBizCstSaveRequestDto,
|
||||
type InstlSncnBzScaleSaveDto,
|
||||
type InstlSncnMng,
|
||||
} from "@/entities/instl-sncn";
|
||||
import { Button, Input } from "@common/components";
|
||||
|
||||
import "./cnfm-page.css";
|
||||
@@ -26,17 +41,80 @@ type BzAmtRow = {
|
||||
thtiDsgn: string;
|
||||
};
|
||||
|
||||
type RequiredAttachKey =
|
||||
| "applyForm"
|
||||
| "planGoalYear"
|
||||
| "adequacyReview"
|
||||
| "supervisionCost"
|
||||
| "costBreakdown"
|
||||
| "designDrawing"
|
||||
| "etc";
|
||||
|
||||
type RequiredAttachItem = {
|
||||
key: RequiredAttachKey;
|
||||
label: string;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
fileName: string;
|
||||
uploading: boolean;
|
||||
};
|
||||
|
||||
const REQUIRED_ATTACHMENTS: Array<{ key: RequiredAttachKey; label: string }> = [
|
||||
{ key: "applyForm", label: "인가신청서" },
|
||||
{ key: "planGoalYear", label: "계획수립 기준 및 목표연도 계획" },
|
||||
{ key: "adequacyReview", label: "하수도 시설계획 적정성 검토 결과" },
|
||||
{ key: "supervisionCost", label: "관로설계시설물 감리비용 산출 계획서" },
|
||||
{ key: "costBreakdown", label: "사업비 내역서" },
|
||||
{ key: "designDrawing", label: "설계도면" },
|
||||
{ key: "etc", label: "기타 첨부자료" },
|
||||
];
|
||||
|
||||
const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
|
||||
const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
|
||||
|
||||
const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0");
|
||||
const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString());
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 공통 셀 컴포넌트 (컴포넌트 외부에 선언해야 re-mount 방지) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input w-full min-w-0 text-right"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
|
||||
const ReadCell = ({ value }: { value: string | number }) => (
|
||||
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 메인 컴포넌트 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function CnfmRegistPage() {
|
||||
const router = useRouter();
|
||||
const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("RE");
|
||||
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { taskSeComCd?: string; taskSeComCdNm?: string };
|
||||
setTaskSeComCd(parsed.taskSeComCd || "RE");
|
||||
setTaskSeComCdNm(parsed.taskSeComCdNm || "재이용수");
|
||||
} catch {
|
||||
// noop
|
||||
} finally {
|
||||
sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── 기본 정보 ── */
|
||||
const [bzNm, setBzNm] = useState("");
|
||||
@@ -59,26 +137,263 @@ function CnfmRegistPage() {
|
||||
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
|
||||
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
|
||||
|
||||
/* ── 체크리스트 ── */
|
||||
const CHECKLIST_ITEMS = [
|
||||
"사업계획의 적정성",
|
||||
"사업규모의 적정성",
|
||||
"사업방식의 적정성",
|
||||
"사업비의 적정성",
|
||||
"사업효과의 적정성",
|
||||
"사업일정의 적정성",
|
||||
];
|
||||
const [checkResults, setCheckResults] = useState<Record<string, "Y" | "N" | "">>(() =>
|
||||
Object.fromEntries(CHECKLIST_ITEMS.map((item) => [item, "" as "Y" | "N" | ""])),
|
||||
/* ── 필수 첨부파일 ── */
|
||||
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(() =>
|
||||
REQUIRED_ATTACHMENTS.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.key] = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
atflCd: "",
|
||||
atflId: "",
|
||||
fileName: "",
|
||||
uploading: false,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<RequiredAttachKey, RequiredAttachItem>,
|
||||
),
|
||||
);
|
||||
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({
|
||||
applyForm: null,
|
||||
planGoalYear: null,
|
||||
adequacyReview: null,
|
||||
supervisionCost: null,
|
||||
costBreakdown: null,
|
||||
designDrawing: null,
|
||||
etc: null,
|
||||
});
|
||||
|
||||
/* ── 사업규모 ── */
|
||||
const [bzSpecWwtp, setBzSpecWwtp] = useState<BzScaleRow>(emptyScale()); // 처리장용량
|
||||
const [bzSpecArea, setBzSpecArea] = useState<BzScaleRow>(emptyScale()); // 면적
|
||||
const [bzSpecNcr, setBzSpecNcr] = useState<BzScaleRow>(emptyScale()); // 하수관로 신설
|
||||
const [bzSpecRplc, setBzSpecRplc] = useState<BzScaleRow>(emptyScale()); // 교체(보수)
|
||||
const [bzSpecPump, setBzSpecPump] = useState<BzScaleRow>(emptyScale()); // 펌프장
|
||||
const [bzSpecDrng, setBzSpecDrng] = useState<BzScaleRow>(emptyScale()); // 배수설비
|
||||
const uploadRequiredFile = (file: File, key: RequiredAttachKey) => {
|
||||
setRequiredFiles((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], uploading: true },
|
||||
}));
|
||||
|
||||
setCookie("atfl-cd-key", "");
|
||||
|
||||
const upload = new Upload(file, {
|
||||
endpoint: ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD,
|
||||
chunkSize: 10 * 1024 * 1024,
|
||||
headers: {
|
||||
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
|
||||
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
|
||||
},
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
},
|
||||
onError(error: Error) {
|
||||
console.error("필수 첨부파일 업로드 실패", error);
|
||||
setRequiredFiles((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], uploading: false },
|
||||
}));
|
||||
alert("필수 첨부파일 업로드 중 오류가 발생했습니다.");
|
||||
},
|
||||
onAfterResponse(_req: unknown, res: HttpResponse) {
|
||||
const fileInfo = res.getHeader("X-File-Info");
|
||||
if (typeof fileInfo !== "string") return;
|
||||
|
||||
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
|
||||
const atflCd = parsed.atflCd ?? "";
|
||||
const atflId = parsed.atflId ?? "";
|
||||
if (atflCd) setCookie("atfl-cd-key", atflCd);
|
||||
|
||||
setRequiredFiles((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
atflCd,
|
||||
atflId,
|
||||
fileName: file.name,
|
||||
uploading: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
onSuccess() {
|
||||
setRequiredFiles((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], uploading: false },
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
upload.start();
|
||||
};
|
||||
|
||||
const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadRequiredFile(file, key);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
/* ── 체크리스트 ── */
|
||||
type CheckResultVal = "Y" | "N" | null;
|
||||
const [checkResults, setCheckResults] = useState<Record<string, CheckResultVal>>({});
|
||||
const cklstRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
||||
|
||||
const { data: cklstTree = [] } = useQuery<CklstTreeNode[]>({
|
||||
queryKey: ["cklstTree", "INSTL", taskSeComCd],
|
||||
queryFn: () => cklstMngService.findTree({ docSeComCd: "INSTL", taskSeComCd }),
|
||||
enabled: Boolean(taskSeComCd),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const cklstRows = useMemo(() => {
|
||||
// bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함
|
||||
const useRows = cklstTree;
|
||||
if (useRows.length === 0) return [] as CklstTreeNode[];
|
||||
|
||||
// artcl 단위로 분리하지 않고 전체 rows에서 단일 childMap을 구성한다.
|
||||
// upCklstRvwSn이 다른 artcl 소속 노드를 부모로 가질 수 있으므로
|
||||
// artcl 경계를 넘는 부모-자식 관계도 처리해야 한다.
|
||||
const childMap = new Map<number | null, CklstTreeNode[]>();
|
||||
for (const row of useRows) {
|
||||
const parent = row.upCklstRvwSn ?? null;
|
||||
if (!childMap.has(parent)) childMap.set(parent, []);
|
||||
childMap.get(parent)!.push(row);
|
||||
}
|
||||
|
||||
for (const list of childMap.values()) {
|
||||
list.sort((a, b) =>
|
||||
a.cklstArtclSn !== b.cklstArtclSn
|
||||
? a.cklstArtclSn - b.cklstArtclSn
|
||||
: a.sortSeq !== b.sortSeq
|
||||
? a.sortSeq - b.sortSeq
|
||||
: a.cklstRvwSn - b.cklstRvwSn,
|
||||
);
|
||||
}
|
||||
|
||||
const ordered: CklstTreeNode[] = [];
|
||||
const walk = (parent: number | null) => {
|
||||
const children = childMap.get(parent) ?? [];
|
||||
for (const child of children) {
|
||||
ordered.push(child);
|
||||
walk(child.cklstRvwSn);
|
||||
}
|
||||
};
|
||||
walk(null);
|
||||
|
||||
return ordered;
|
||||
}, [cklstTree]);
|
||||
|
||||
const cklstKey = (row: CklstTreeNode) =>
|
||||
`${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`;
|
||||
|
||||
useEffect(() => {
|
||||
setCheckResults((prev) => {
|
||||
const next: Record<string, CheckResultVal> = {};
|
||||
let changed = false;
|
||||
|
||||
cklstRows.forEach((row) => {
|
||||
const key = cklstKey(row);
|
||||
if (Object.prototype.hasOwnProperty.call(prev, key)) {
|
||||
next[key] = prev[key];
|
||||
} else {
|
||||
next[key] = null;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 기존 key가 사라졌거나 값이 달라진 경우만 상태 업데이트
|
||||
if (!changed) {
|
||||
const prevKeys = Object.keys(prev);
|
||||
if (prevKeys.length !== Object.keys(next).length) {
|
||||
changed = true;
|
||||
} else {
|
||||
for (const key of prevKeys) {
|
||||
if (!(key in next) || prev[key] !== next[key]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [cklstRows]);
|
||||
|
||||
const cklstStats = useMemo(() => {
|
||||
const total = cklstRows.length;
|
||||
const completed = cklstRows.reduce((acc, row) => {
|
||||
const val = checkResults[cklstKey(row)] ?? "";
|
||||
return acc + (val ? 1 : 0);
|
||||
}, 0);
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { total, completed, percent };
|
||||
}, [cklstRows, checkResults]);
|
||||
|
||||
const toCklstSaveDtoList = () =>
|
||||
cklstRows
|
||||
.map((row) => ({
|
||||
cklstSn: row.cklstSn,
|
||||
cklstArtclSn: row.cklstArtclSn,
|
||||
cklstRvwSn: row.cklstRvwSn,
|
||||
selected: checkResults[cklstKey(row)] ?? null,
|
||||
}));
|
||||
|
||||
const toEsntlAtchFileSaveDtoList = () =>
|
||||
Object.values(requiredFiles)
|
||||
.filter((item) => Boolean(item.atflCd) && Boolean(item.atflId))
|
||||
.map((item) => ({
|
||||
esntlAtchFileId: item.key,
|
||||
atchFileGroupId: item.atflCd,
|
||||
}));
|
||||
|
||||
/* ── 사업규모 (공종구분 트리 연동) ── */
|
||||
// cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
|
||||
const [bzScaleMap, setBzScaleMap] = useState<Record<string, BzScaleRow>>({});
|
||||
|
||||
/* ── 공종구분 트리 로드 ── */
|
||||
const { data: cnstpTree = [] } = useQuery<CnstpSeMngTreeDto[]>({
|
||||
queryKey: ["cnstpTree"],
|
||||
queryFn: cnstpService.findTree,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
/* ── 공종구분 리프 노드 집합 ── */
|
||||
const cnstpLeafSet = useMemo(() => {
|
||||
const parentCodes = new Set(cnstpTree.map((n) => n.upCnstpCd).filter(Boolean) as string[]);
|
||||
return new Set(cnstpTree.filter((n) => !parentCodes.has(n.cnstpSeCd)).map((n) => n.cnstpSeCd));
|
||||
}, [cnstpTree]);
|
||||
|
||||
const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
|
||||
cnstpTree
|
||||
.filter((n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd))
|
||||
.reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0);
|
||||
|
||||
const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
|
||||
cnstpLeafSet.has(node.cnstpSeCd)
|
||||
? num((bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col])
|
||||
: subtotalScale(node, col);
|
||||
|
||||
const handleScaleInput = (cnstpSeCd: string, col: keyof BzScaleRow, val: string) =>
|
||||
setBzScaleMap((prev) => ({
|
||||
...prev,
|
||||
[cnstpSeCd]: { ...(prev[cnstpSeCd] ?? emptyScale()), [col]: val },
|
||||
}));
|
||||
|
||||
/** 사업규모 맵 -> 백엔드 저장용 배열 DTO 변환 */
|
||||
const toBzScaleSaveDtoList = (): InstlSncnBzScaleSaveDto[] =>
|
||||
cnstpTree
|
||||
.filter((node) => cnstpLeafSet.has(node.cnstpSeCd))
|
||||
.map((node) => {
|
||||
const row = bzScaleMap[node.cnstpSeCd] ?? emptyScale();
|
||||
const bscPl = num(row.bscPl);
|
||||
const budgetReflct = num(row.budgetReflct);
|
||||
const thtiDsgn = num(row.thtiDsgn);
|
||||
|
||||
return {
|
||||
cnstpSeCd: node.cnstpSeCd,
|
||||
bscPl,
|
||||
budgetReflct,
|
||||
thtiDsgn,
|
||||
diff: thtiDsgn - budgetReflct,
|
||||
};
|
||||
});
|
||||
|
||||
/* ── 총사업비 (회계구분 트리 연동) ── */
|
||||
// acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
|
||||
@@ -120,6 +435,20 @@ function CnfmRegistPage() {
|
||||
[acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val },
|
||||
}));
|
||||
|
||||
/** 총사업비 맵 -> 백엔드 저장용 배열 DTO 변환 */
|
||||
const toBizCstSaveDtoList = (): InstlSncnBizCstSaveRequestDto[] =>
|
||||
acntgTree
|
||||
.filter((node) => leafSet.has(node.acntgSeCd))
|
||||
.map((node) => {
|
||||
const row = amtMap[node.acntgSeCd] ?? emptyAmt();
|
||||
return {
|
||||
acntgSeCd: node.acntgSeCd,
|
||||
bscPlanAmt: num(row.bscPl),
|
||||
bgtRfltAmt: num(row.budgetReflct),
|
||||
tstmDesignAmt: num(row.thtiDsgn),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환
|
||||
* (공백 4칸 = 1 depth = 1rem)
|
||||
@@ -137,11 +466,19 @@ function CnfmRegistPage() {
|
||||
return "";
|
||||
};
|
||||
|
||||
/** 공종 트리 행 스타일 분류 */
|
||||
const scaleRowStyle = (node: CnstpSeMngTreeDto) => {
|
||||
if (node.treeLevel === 1) return "bg-gray-50 font-semibold";
|
||||
if (!cnstpLeafSet.has(node.cnstpSeCd)) return "font-semibold";
|
||||
return "";
|
||||
};
|
||||
|
||||
/* ── 증감 라벨 ── */
|
||||
const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v));
|
||||
|
||||
/* ── 검증 ── */
|
||||
function fnValidation(mode: "SAVE" | "PRESENTN") {
|
||||
/*
|
||||
if (!bzNm.trim()) {
|
||||
alert("사업명을 입력하세요.");
|
||||
return false;
|
||||
@@ -158,44 +495,102 @@ function CnfmRegistPage() {
|
||||
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 체크리스트 사용 항목(Y) 중 미선택 항목이 있으면 진행 불가
|
||||
const hasUncheckedChecklist = cklstRows.some(
|
||||
(row) => !checkResults[cklstKey(row)],
|
||||
);
|
||||
if (hasUncheckedChecklist) {
|
||||
alert("체크리스트를 모두 체크하세요.");
|
||||
return false;
|
||||
}
|
||||
// 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함
|
||||
*/
|
||||
if (mode === "SAVE") {
|
||||
const invalidYesRow = cklstRows.find(
|
||||
(row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row)] !== "Y",
|
||||
);
|
||||
if (invalidYesRow) {
|
||||
const invalidKey = cklstKey(invalidYesRow);
|
||||
const invalidRowEl = cklstRowRefs.current[invalidKey];
|
||||
invalidRowEl?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const yesRadio = invalidRowEl?.querySelector<HTMLInputElement>(
|
||||
`input[name="chk_${invalidKey}"][value="Y"]`,
|
||||
);
|
||||
yesRadio?.focus();
|
||||
alert(`${invalidYesRow.rvwCn} 검토내용은 예(Y)여야 합니다.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"): InstlSncnMng => ({
|
||||
instlSncnDocNo: "",
|
||||
dongId: "",
|
||||
sidoNm: "",
|
||||
sggNm: "",
|
||||
chrgrNm: "",
|
||||
instNm: "",
|
||||
envoSeComCd: instlCnfmApr,
|
||||
envoSeCdNm: "",
|
||||
taskSeComCd,
|
||||
taskSeCdNm: taskSeComCdNm,
|
||||
bgtBizCd: "",
|
||||
bgtAplyYr: "",
|
||||
bizNm: bzNm,
|
||||
bizBgngYr: bzBgngYr,
|
||||
bizEndYr: bzEndYr,
|
||||
bizCn: bzCn,
|
||||
aplcntNm: "",
|
||||
aplcntMblTelno: "",
|
||||
aplcntOgdpNm: "",
|
||||
aplcntEmlAddr: "",
|
||||
dsgnrNm,
|
||||
dsgnrTelno,
|
||||
dsgnrOgdpNm: dsgnrPsitn,
|
||||
dsgnrEmlAddr,
|
||||
atchFileGroupId: "",
|
||||
prgrsSttsComCd,
|
||||
prgrsSttsCdNm: "",
|
||||
regDt: "",
|
||||
mdfcnDt: "",
|
||||
rgtrNm: "",
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
if (!fnValidation("SAVE")) return;
|
||||
alert("저장(임시) API 연동 후 progrsSttus=SV 전송");
|
||||
const payload: InstlSncnApplySaveRequestDto = {
|
||||
instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장)
|
||||
bzScaleList: toBzScaleSaveDtoList(),
|
||||
bizCstList: toBizCstSaveDtoList(),
|
||||
cklstResultList: toCklstSaveDtoList(),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
|
||||
};
|
||||
|
||||
try {
|
||||
const issuedDocNo = await InstlSncnApi.saveApply(payload);
|
||||
alert(`저장되었습니다. 문서번호: ${issuedDocNo}`);
|
||||
} catch (error) {
|
||||
console.error("저장 API 호출 실패", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!fnValidation("PRESENTN")) return;
|
||||
const payload = {
|
||||
instlSncnMng: toInstlSncnMngPayload("ST"),
|
||||
bzScaleList: toBzScaleSaveDtoList(),
|
||||
bizCstList: toBizCstSaveDtoList(),
|
||||
cklstRvwRsltList: toCklstSaveDtoList(),
|
||||
};
|
||||
void payload;
|
||||
alert("제출 API 연동 후 progrsSttus=ST 전송");
|
||||
}
|
||||
|
||||
/* ── 공통 입력 핸들러 ── */
|
||||
function scaleInput(
|
||||
setter: React.Dispatch<React.SetStateAction<BzScaleRow>>,
|
||||
col: keyof BzScaleRow,
|
||||
val: string,
|
||||
) {
|
||||
setter((prev) => ({ ...prev, [col]: val }));
|
||||
}
|
||||
|
||||
/* ── 공통 숫자 셀 ── */
|
||||
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input w-full text-right"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
|
||||
const ReadCell = ({ value }: { value: string | number }) => (
|
||||
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
@@ -290,7 +685,8 @@ function CnfmRegistPage() {
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<td>
|
||||
<div className="flex gap-4 text-[1.3rem]">하수관로</div>
|
||||
<div className="flex gap-4 text-[1.3rem]">{taskSeComCdNm}</div>
|
||||
<input type="hidden" name="taskSeComCd" value={taskSeComCd} />
|
||||
</td>
|
||||
<th>신청 지자체</th>
|
||||
<td>
|
||||
@@ -438,7 +834,7 @@ function CnfmRegistPage() {
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2} className="w-[12rem]">
|
||||
<th rowSpan={2} className="w-[30rem]">
|
||||
구분
|
||||
</th>
|
||||
<th>기본계획(A)</th>
|
||||
@@ -448,31 +844,52 @@ function CnfmRegistPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ label: "처리장용량(㎥/일)", state: bzSpecWwtp, setter: setBzSpecWwtp },
|
||||
{ label: "면적(㎡)", state: bzSpecArea, setter: setBzSpecArea },
|
||||
{ label: "하수관로 신설(m)", state: bzSpecNcr, setter: setBzSpecNcr },
|
||||
{ label: "교체/보수(m)", state: bzSpecRplc, setter: setBzSpecRplc },
|
||||
{ label: "펌프장(개소)", state: bzSpecPump, setter: setBzSpecPump },
|
||||
{ label: "배수설비(개소)", state: bzSpecDrng, setter: setBzSpecDrng },
|
||||
].map(({ label, state, setter }) => (
|
||||
<tr key={label}>
|
||||
<td className="bg-gray-50 pl-2 text-left font-semibold">{label}</td>
|
||||
{(["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
|
||||
<td key={col}>
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input w-full text-right"
|
||||
value={state[col]}
|
||||
onChange={(e) => scaleInput(setter, col, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">
|
||||
{diffLabel(num(state.thtiDsgn) - num(state.budgetReflct))}
|
||||
{cnstpTree.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="cnfm-td-empty">
|
||||
조회된 사업규모 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : (
|
||||
cnstpTree.map((node) => {
|
||||
const isLeaf = cnstpLeafSet.has(node.cnstpSeCd);
|
||||
const bscPl = scaleNodeVal(node, "bscPl");
|
||||
const budgetReflct = scaleNodeVal(node, "budgetReflct");
|
||||
const thtiDsgn = scaleNodeVal(node, "thtiDsgn");
|
||||
const diffVal = thtiDsgn - budgetReflct;
|
||||
const pl = indentPx(node.indCnstpSeCdNm);
|
||||
|
||||
return (
|
||||
<tr key={node.cnstpSeCd} className={scaleRowStyle(node)}>
|
||||
<td
|
||||
className="bg-gray-50 pl-2 text-left"
|
||||
style={{ paddingLeft: `${pl + 8}px` }}
|
||||
>
|
||||
{node.cnstpSeCdNm}
|
||||
</td>
|
||||
|
||||
{isLeaf
|
||||
? (["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
|
||||
<td key={col}>
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input w-full text-right"
|
||||
value={(bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col]}
|
||||
onChange={(e) =>
|
||||
handleScaleInput(node.cnstpSeCd, col, e.target.value)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))
|
||||
: ([bscPl, budgetReflct, thtiDsgn] as number[]).map((val, idx) => (
|
||||
<ReadCell key={`${node.cnstpSeCd}_${idx}`} value={fmt(val)} />
|
||||
))}
|
||||
|
||||
<ReadCell value={diffLabel(diffVal)} />
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -484,24 +901,20 @@ function CnfmRegistPage() {
|
||||
총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span>
|
||||
</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<table className="cnfm-table w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2} className="w-[20rem]">
|
||||
<th colSpan={2} className="w-[30rem]" rowSpan={2}>
|
||||
구분
|
||||
</th>
|
||||
<th>예산반영(A)</th>
|
||||
<th>표준사업비(B)</th>
|
||||
<th>금회설계시(C)</th>
|
||||
<th rowSpan={2}>예산반영(A)</th>
|
||||
<th rowSpan={2}>표준사업비(B)</th>
|
||||
<th rowSpan={2}>금회설계시(C)</th>
|
||||
<th colSpan={2}>증감</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colSpan={2}></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>(C-B)</th>
|
||||
<th>%</th>
|
||||
<th className="w-[12rem]">(C-B)</th>
|
||||
<th className="w-[6rem]">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -583,11 +996,13 @@ function CnfmRegistPage() {
|
||||
<div className="cnfm-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<button className="cnfm-btn-search text-[1.2rem]">하수관로(0/8)</button>
|
||||
<button className="cnfm-btn-search text-[1.2rem]">
|
||||
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) + `(${cklstStats.completed}/${cklstStats.total})`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 text-[1.25rem] text-gray-500">
|
||||
체크리스트 진행상황 — 총합: <strong>0</strong> · 완료: <strong>0</strong> · 진행률:{" "}
|
||||
<strong>0%</strong>
|
||||
체크리스트 진행상황 — 총합: <strong>{cklstStats.total}</strong> · 완료:{" "}
|
||||
<strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
|
||||
</div>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
@@ -606,7 +1021,7 @@ function CnfmRegistPage() {
|
||||
className="w-full rounded border border-green-300 bg-green-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-green-600"
|
||||
onClick={() =>
|
||||
setCheckResults(
|
||||
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "Y" as const])),
|
||||
Object.fromEntries(cklstRows.map((row) => [cklstKey(row), "Y" as const])),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -619,7 +1034,7 @@ function CnfmRegistPage() {
|
||||
className="w-full rounded border border-red-300 bg-red-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-red-600"
|
||||
onClick={() =>
|
||||
setCheckResults(
|
||||
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "N" as const])),
|
||||
Object.fromEntries(cklstRows.map((row) => [cklstKey(row), "N" as const])),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -629,42 +1044,61 @@ function CnfmRegistPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CHECKLIST_ITEMS.map((item) => (
|
||||
<tr key={item}>
|
||||
<td className="pl-2 text-left">{item}</td>
|
||||
<td className="pl-2 text-left text-[1.2rem] text-gray-400">
|
||||
금회 총사업비가 확정예산 범위 내
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${item}`}
|
||||
value="Y"
|
||||
checked={checkResults[item] === "Y"}
|
||||
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "Y" }))}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${item}`}
|
||||
value="N"
|
||||
checked={checkResults[item] === "N"}
|
||||
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "N" }))}
|
||||
/>
|
||||
</label>
|
||||
{cklstRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="cnfm-td-empty">
|
||||
조회된 체크리스트 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : (
|
||||
cklstRows.map((row) => {
|
||||
const key = cklstKey(row);
|
||||
const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
cklstRowRefs.current[key] = el;
|
||||
}}
|
||||
>
|
||||
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
|
||||
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: rvwPaddingLeft }}>
|
||||
{row.treeLevel > 1 ? "└ " : ""}
|
||||
{row.rvwCn}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="Y"
|
||||
checked={checkResults[key] === "Y"}
|
||||
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="N"
|
||||
checked={checkResults[key] === "N"}
|
||||
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "N" }))}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 첨부파일 ── */}
|
||||
{/* ── 필수 첨부파일 ── */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">첨부파일</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
@@ -677,17 +1111,9 @@ function CnfmRegistPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
"인가신청서",
|
||||
"계획수립 기준 및 목표연도 계획",
|
||||
"하수도 시설계획 적정성 검토 결과",
|
||||
"관로설계시설물 감리비용 산출 계획서",
|
||||
"사업비 내역서",
|
||||
"설계도면",
|
||||
"기타 첨부자료",
|
||||
].map((name) => (
|
||||
<tr key={name}>
|
||||
<td className="pl-2 text-left">{name}</td>
|
||||
{REQUIRED_ATTACHMENTS.map(({ key, label }) => (
|
||||
<tr key={key}>
|
||||
<td className="pl-2 text-left">{label}</td>
|
||||
<td>hwp/pdf</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -695,9 +1121,25 @@ function CnfmRegistPage() {
|
||||
type="text"
|
||||
placeholder="첨부문서를 올려주세요"
|
||||
className="cnfm-input flex-1"
|
||||
value={requiredFiles[key]?.uploading ? "업로드 중..." : requiredFiles[key]?.fileName || ""}
|
||||
readOnly
|
||||
/>
|
||||
<button className="cnfm-btn-search text-[1.2rem]">첨부하기</button>
|
||||
<input
|
||||
ref={(el) => {
|
||||
requiredFileInputRefs.current[key] = el;
|
||||
}}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => handleRequiredFileChange(key, e)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="cnfm-btn-search text-[1.2rem]"
|
||||
onClick={() => requiredFileInputRefs.current[key]?.click()}
|
||||
disabled={requiredFiles[key]?.uploading}
|
||||
>
|
||||
{requiredFiles[key]?.uploading ? "업로드중" : "첨부하기"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export const ENDPOINT = {
|
||||
ATTACH_SERVICE: {
|
||||
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
|
||||
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
|
||||
},
|
||||
AS_COMMON_SERVICE: {
|
||||
ACNTG_SE: "/as-common-service/api/v1/acntg-se",
|
||||
CNSTP_SE: "/as-common-service/api/v1/cnstp-se",
|
||||
},
|
||||
AS_INSTL_SERVICE: {
|
||||
INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn",
|
||||
|
||||
Reference in New Issue
Block a user