diff --git a/apps/as/src/entities/cklst/api/cklst-mng-service.ts b/apps/as/src/entities/cklst/api/cklst-mng-service.ts index 67f22d3b1..d3b6713e8 100644 --- a/apps/as/src/entities/cklst/api/cklst-mng-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-mng-service.ts @@ -7,6 +7,8 @@ import type { CklstMngSaveRequest, CklstMngSearchParams, CklstMngService, + CklstTreeNode, + CklstTreeSearchParams, } from "../model/cklst-mng-interface"; async function findPage(params: CklstMngSearchParams): Promise> { @@ -21,6 +23,13 @@ async function findById(cklstSn: number): Promise { return response.data; } +async function findTree(params: CklstTreeSearchParams): Promise { + const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/tree`, { + params, + }); + return response.data; +} + async function create(body: CklstMngSaveRequest): Promise { const response = await client.post(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body); return response.data; @@ -42,6 +51,7 @@ async function remove(cklstSn: number): Promise { export const cklstMngService = { findPage, findById, + findTree, create, update, remove, diff --git a/apps/as/src/entities/cklst/index.ts b/apps/as/src/entities/cklst/index.ts index 499003f90..ccdd787ee 100644 --- a/apps/as/src/entities/cklst/index.ts +++ b/apps/as/src/entities/cklst/index.ts @@ -26,4 +26,6 @@ export type { CklstMngSaveRequest, CklstMngSearchParams, CklstMngService, + CklstTreeNode, + CklstTreeSearchParams, } from "./model/cklst-mng-interface"; diff --git a/apps/as/src/entities/cklst/model/cklst-mng-interface.ts b/apps/as/src/entities/cklst/model/cklst-mng-interface.ts index ad1302b00..7e3187ce7 100644 --- a/apps/as/src/entities/cklst/model/cklst-mng-interface.ts +++ b/apps/as/src/entities/cklst/model/cklst-mng-interface.ts @@ -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>; findById(cklstSn: number): Promise; + findTree(params: CklstTreeSearchParams): Promise; create(body: CklstMngSaveRequest): Promise; update(cklstSn: number, body: CklstMngSaveRequest): Promise; remove(cklstSn: number): Promise; diff --git a/apps/as/src/entities/cnstp/api/cnstp-service.ts b/apps/as/src/entities/cnstp/api/cnstp-service.ts new file mode 100644 index 000000000..00eb80371 --- /dev/null +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`, + ); + return response.data; +} + +export const cnstpService = { + findTree, +} satisfies CnstpService; diff --git a/apps/as/src/entities/cnstp/index.ts b/apps/as/src/entities/cnstp/index.ts new file mode 100644 index 000000000..1415c59bd --- /dev/null +++ b/apps/as/src/entities/cnstp/index.ts @@ -0,0 +1,2 @@ +export type { CnstpSeMngTreeDto, CnstpService } from "./model/cnstp-interface"; +export { cnstpService } from "./api/cnstp-service"; diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts new file mode 100644 index 000000000..d68f0625b --- /dev/null +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -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; +} diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index a1959a277..a85a145b5 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -1,6 +1,7 @@ export { InstlSncnApi } from "./api/instl-sncn-service"; export { INSTL_SNCN_REGISTER_SELECTION_KEY, + type InstlSncnBzScaleSaveDto, type InstlSncnMng, type InstlSncnMngSearchParams, type SpringPage, diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index d0c19aa35..1bf43482f 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -54,6 +54,32 @@ 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; +} + export interface InstlSncnMngService { getPage: (params: InstlSncnMngSearchParams) => Promise>; getById: (id: string) => Promise; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index 760b410df..128cae223 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -7,7 +7,14 @@ import { useQuery } from "@tanstack/react-query"; import type { AcntgSeMngTreeDto } from "@/entities/acntg"; import { acntgService } from "@/entities/acntg"; -import { INSTL_SNCN_REGISTER_SELECTION_KEY } from "@/entities/instl-sncn"; +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, + type InstlSncnBzScaleSaveDto, +} from "@/entities/instl-sncn"; import { Button, Input } from "@common/components"; import "./cnfm-page.css"; @@ -96,25 +103,139 @@ function CnfmRegistPage() { const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); /* ── 체크리스트 ── */ - const CHECKLIST_ITEMS = [ - "사업계획의 적정성", - "사업규모의 적정성", - "사업방식의 적정성", - "사업비의 적정성", - "사업효과의 적정성", - "사업일정의 적정성", - ]; - const [checkResults, setCheckResults] = useState>(() => - Object.fromEntries(CHECKLIST_ITEMS.map((item) => [item, "" as "Y" | "N" | ""])), - ); + type CheckResultVal = "Y" | "N" | ""; + const [checkResults, setCheckResults] = useState>({}); - /* ── 사업규모 ── */ - const [bzSpecWwtp, setBzSpecWwtp] = useState(emptyScale()); // 처리장용량 - const [bzSpecArea, setBzSpecArea] = useState(emptyScale()); // 면적 - const [bzSpecNcr, setBzSpecNcr] = useState(emptyScale()); // 하수관로 신설 - const [bzSpecRplc, setBzSpecRplc] = useState(emptyScale()); // 교체(보수) - const [bzSpecPump, setBzSpecPump] = useState(emptyScale()); // 펌프장 - const [bzSpecDrng, setBzSpecDrng] = useState(emptyScale()); // 배수설비 + const { data: cklstTree = [] } = useQuery({ + 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(); + 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 = {}; + cklstRows.forEach((row) => { + const key = cklstKey(row); + next[key] = prev[key] ?? ""; + }); + return next; + }); + }, [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)] ?? "", + })) + .filter((row) => row.selected !== ""); + + /* ── 사업규모 (공종구분 트리 연동) ── */ + // cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map + const [bzScaleMap, setBzScaleMap] = useState>({}); + + /* ── 공종구분 트리 로드 ── */ + const { data: cnstpTree = [] } = useQuery({ + 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 @@ -173,6 +294,13 @@ 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)); @@ -201,29 +329,24 @@ function CnfmRegistPage() { if (!fnValidation("SAVE")) return; const payload = { taskSeComCd, - }; + bzScaleList: toBzScaleSaveDtoList(), + cklstResultList: toCklstSaveDtoList(), + }; void payload; - alert("저장(임시) API 연동 후 progrsSttus=SV 전송"); + alert("저장(임시) API 연동 후 progrsSttus=SV 전송",JSON.stringify(payload, null, 2)); } function handleSubmit() { if (!fnValidation("PRESENTN")) return; const payload = { taskSeComCd, + bzScaleList: toBzScaleSaveDtoList(), + cklstResultList: toCklstSaveDtoList(), }; void payload; alert("제출 API 연동 후 progrsSttus=ST 전송"); } - /* ── 공통 입력 핸들러 ── */ - function scaleInput( - setter: React.Dispatch>, - col: keyof BzScaleRow, - val: string, - ) { - setter((prev) => ({ ...prev, [col]: val })); - } - /* ---------------------------------------------------------------- */ return (
@@ -467,7 +590,7 @@ function CnfmRegistPage() { - @@ -477,31 +600,52 @@ function CnfmRegistPage() { - {[ - { 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 }) => ( - - - {(["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => ( - - ))} - + - ))} + ) : ( + 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 ( + + + + {isLeaf + ? (["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => ( + + )) + : ([bscPl, budgetReflct, thtiDsgn] as number[]).map((val, idx) => ( + + ))} + + + + ); + }) + )}
+ 구분 기본계획(A)
{label} - scaleInput(setter, col, e.target.value)} - /> - - {diffLabel(num(state.thtiDsgn) - num(state.budgetReflct))} + {cnstpTree.length === 0 ? ( +
+ 조회된 사업규모 항목이 없습니다.
+ {node.cnstpSeCdNm} + + + handleScaleInput(node.cnstpSeCd, col, e.target.value) + } + /> +
@@ -608,11 +752,13 @@ function CnfmRegistPage() {

체크리스트

- +
- 체크리스트 진행상황 — 총합: 0 · 완료: 0 · 진행률:{" "} - 0% + 체크리스트 진행상황 — 총합: {cklstStats.total} · 완료:{" "} + {cklstStats.completed} · 진행률: {cklstStats.percent}%
@@ -631,7 +777,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])), ) } > @@ -644,7 +790,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])), ) } > @@ -654,36 +800,50 @@ function CnfmRegistPage() { - {CHECKLIST_ITEMS.map((item) => ( - - - - - + - ))} + ) : ( + cklstRows.map((row) => { + const key = cklstKey(row); + const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`; + + return ( + + + + + + + ); + }) + )}
{item} - 금회 총사업비가 확정예산 범위 내 - - - - + {cklstRows.length === 0 ? ( +
+ 조회된 체크리스트 항목이 없습니다.
{row.cklstArtclNm} + {row.treeLevel > 1 ? "└ " : ""} + {row.rvwCn} + + + + +
diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index 916dd3071..da0da55c8 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -1,6 +1,7 @@ export const ENDPOINT = { 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",