From 9dec42a58405d70285fcf323e54c53d4adbf6539 Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 13:42:50 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[as=20instl]=20=EC=84=A4=EC=B9=98=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EB=93=B1=EB=A1=9D=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) 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 82e41bbfe..18e419d6f 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 @@ -32,6 +32,24 @@ 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 }) => ( + + onChange(e.target.value)} + /> + +); + +const ReadCell = ({ value }: { value: string | number }) => ( + {value} +); + /* ------------------------------------------------------------------ */ /* 메인 컴포넌트 */ /* ------------------------------------------------------------------ */ @@ -180,22 +198,6 @@ function CnfmRegistPage() { setter((prev) => ({ ...prev, [col]: val })); } - /* ── 공통 숫자 셀 ── */ - const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( - - onChange(e.target.value)} - /> - - ); - - const ReadCell = ({ value }: { value: string | number }) => ( - {value} - ); - /* ---------------------------------------------------------------- */ return (
@@ -484,24 +486,20 @@ function CnfmRegistPage() { 총사업비 (단위: 백만원)
- +
- - - - + + + - - - - - - + + From d864cf73b9e9ecb8fd19b71ffc68cb6275df81dc Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 14:33:25 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[as=20instl]=20=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=EC=8B=9C=20=EC=97=85?= =?UTF-8?q?=EB=AC=B4=EA=B5=AC=EB=B6=84=20=ED=8C=9D=EC=97=85=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A1=9C=EC=A7=81=EC=9C=BC=EB=A1=9C=20=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/ui/cnfm-list-page.tsx | 85 ++++++++++++++++++- .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 23 ++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx index 8b0bb9f38..0b126f54c 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx @@ -1,11 +1,12 @@ "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 { useCodeList } from "@/entities/code"; import { InstlSncnApi, type InstlSncnMngSearchParams } from "@/entities/instl-sncn"; import "./cnfm-page.css"; @@ -65,6 +66,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("FNRS"); + + 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 +103,23 @@ 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; + + const query = new URLSearchParams({ + taskSeComCd: selected.lwpoCd, + taskSeComCdNm: selected.lwpoCdNm, + }); + setTaskDialogOpen(false); + router.push(`/instl-sncn/register?${query.toString()}`); + }; + return (
{/* 검색 영역 */} @@ -160,7 +189,7 @@ function CnfmListPage() { @@ -311,6 +340,58 @@ function CnfmListPage() {
+ + {taskDialogOpen && ( +
+
+
+

업무구분 선택

+ +
+ +
+
+ {fnrsCodeList.map((item) => ( + + ))} +
+
+ +
+ + +
+
+
+ )} ); } 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 18e419d6f..9fbd1f552 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 @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; @@ -55,6 +55,14 @@ const ReadCell = ({ value }: { value: string | number }) => ( /* ------------------------------------------------------------------ */ function CnfmRegistPage() { const router = useRouter(); + const searchParams = useSearchParams(); + + const taskSeComCdNm = searchParams?.get("taskSeComCdNm") || "하수관로"; + const [taskSeComCd, setTaskSeComCd] = useState(""); + + useEffect(() => { + setTaskSeComCd(searchParams?.get("taskSeComCd") || ""); + }, [searchParams]); /* ── 기본 정보 ── */ const [bzNm, setBzNm] = useState(""); @@ -181,11 +189,19 @@ function CnfmRegistPage() { function handleSave() { if (!fnValidation("SAVE")) return; + const payload = { + taskSeComCd, + }; + void payload; alert("저장(임시) API 연동 후 progrsSttus=SV 전송"); } function handleSubmit() { if (!fnValidation("PRESENTN")) return; + const payload = { + taskSeComCd, + }; + void payload; alert("제출 API 연동 후 progrsSttus=ST 전송"); } @@ -292,7 +308,8 @@ function CnfmRegistPage() {
+ { + cklstRowRefs.current[key] = el; + }} + > - {[ - "인가신청서", - "계획수립 기준 및 목표연도 계획", - "하수도 시설계획 적정성 검토 결과", - "관로설계시설물 감리비용 산출 계획서", - "사업비 내역서", - "설계도면", - "기타 첨부자료", - ].map((name) => ( - - + {REQUIRED_ATTACHMENTS.map(({ key, label }) => ( + + diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index 5963f4c84..440f3e86b 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -1,4 +1,8 @@ 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", From 21dab0ef1f666347781634f24cddf3c6bdd4743c Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 22 May 2026 13:52:02 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[as]=20feat:=20=EC=B2=B4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EA=B2=B0?= =?UTF-8?q?=EC=9E=AC=EC=84=A0=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/validators.ts | 6 ++ .../ui/elapr-apln-grid.tsx | 10 +--- .../ui/cklst-preview-dialog.tsx | 59 ++++++++++++------- .../ui/cklst-rvw-grid.tsx | 32 ++++++++-- .../checklist-management/ui/cklst-rvw-row.tsx | 9 ++- 5 files changed, 79 insertions(+), 37 deletions(-) diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/pages/approval-line-management/lib/validators.ts index a136d07d7..c5fbe0b71 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/pages/approval-line-management/lib/validators.ts @@ -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 "최종승인자는 한 명만 등록할 수 있습니다."; } diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx index 3111673de..1dd757ff9 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx @@ -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) {
+ 구분 예산반영(A)표준사업비(B)금회설계시(C)예산반영(A)표준사업비(B)금회설계시(C) 증감
(C-B)%(C-B)%
구분 -
하수관로
+
{taskSeComCdNm}
+
신청 지자체 From 9673ee0ed28a354eedf8eb6e677afc41ff20afb0 Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 14:49:39 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[as=20instl]=20=EC=97=85=EB=AC=B4=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=EC=84=A0=ED=83=9D=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/entities/instl-sncn/index.ts | 9 +++---- .../instl-sncn/model/instl-sncn-interface.ts | 2 ++ .../pages/instl-sncn/ui/cnfm-list-page.tsx | 21 ++++++++++------ .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 24 +++++++++++++------ 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index 8fdbb442f..a1959a277 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 type { - InstlSncnMng, - InstlSncnMngSearchParams, - SpringPage, +export { + INSTL_SNCN_REGISTER_SELECTION_KEY, + type InstlSncnMng, + type InstlSncnMngSearchParams, + type SpringPage, } from "./model/instl-sncn-interface"; 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 00a51b76b..d0c19aa35 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 @@ -1,3 +1,5 @@ +export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection"; + export interface SpringPage { content: T[]; totalElements: number; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx index 0b126f54c..a5ff158dd 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx @@ -7,7 +7,11 @@ import { useQuery } from "@tanstack/react-query"; import { dayjs } from "@/shared/lib"; import { useCodeList } from "@/entities/code"; -import { InstlSncnApi, type InstlSncnMngSearchParams } from "@/entities/instl-sncn"; +import { + INSTL_SNCN_REGISTER_SELECTION_KEY, + InstlSncnApi, + type InstlSncnMngSearchParams, +} from "@/entities/instl-sncn"; import "./cnfm-page.css"; @@ -69,7 +73,7 @@ function CnfmListPage() { const [taskDialogOpen, setTaskDialogOpen] = useState(false); const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState(""); - const { data: fnrsCodeList = [] } = useCodeList("FNRS"); + const { data: fnrsCodeList = [] } = useCodeList("INSTL"); useEffect(() => { if (!taskDialogOpen) return; @@ -112,12 +116,15 @@ function CnfmListPage() { const selected = fnrsCodeList.find((item) => item.lwpoCd === selectedTaskSeComCd); if (!selected) return; - const query = new URLSearchParams({ - taskSeComCd: selected.lwpoCd, - taskSeComCdNm: selected.lwpoCdNm, - }); + sessionStorage.setItem( + INSTL_SNCN_REGISTER_SELECTION_KEY, + JSON.stringify({ + taskSeComCd: selected.lwpoCd, + taskSeComCdNm: selected.lwpoCdNm, + }), + ); setTaskDialogOpen(false); - router.push(`/instl-sncn/register?${query.toString()}`); + router.push("/instl-sncn/register"); }; return ( 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 9fbd1f552..760b410df 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 @@ -1,12 +1,13 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; 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 { Button, Input } from "@common/components"; import "./cnfm-page.css"; @@ -55,14 +56,23 @@ const ReadCell = ({ value }: { value: string | number }) => ( /* ------------------------------------------------------------------ */ function CnfmRegistPage() { const router = useRouter(); - const searchParams = useSearchParams(); - - const taskSeComCdNm = searchParams?.get("taskSeComCdNm") || "하수관로"; - const [taskSeComCd, setTaskSeComCd] = useState(""); + const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수"); + const [taskSeComCd, setTaskSeComCd] = useState("RE"); useEffect(() => { - setTaskSeComCd(searchParams?.get("taskSeComCd") || ""); - }, [searchParams]); + 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(""); From ad13875ce8e221daf8e0be99e19ae359c46bc8af Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 16:56:12 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[as=20instl=20service]=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8,=20=EA=B3=B5=EC=A2=85=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/cklst/api/cklst-mng-service.ts | 10 + apps/as/src/entities/cklst/index.ts | 2 + .../cklst/model/cklst-mng-interface.ts | 19 + .../src/entities/cnstp/api/cnstp-service.ts | 15 + apps/as/src/entities/cnstp/index.ts | 2 + .../entities/cnstp/model/cnstp-interface.ts | 23 ++ apps/as/src/entities/instl-sncn/index.ts | 1 + .../instl-sncn/model/instl-sncn-interface.ts | 26 ++ .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 334 +++++++++++++----- apps/as/src/shared/config/api.ts | 1 + 10 files changed, 346 insertions(+), 87 deletions(-) create mode 100644 apps/as/src/entities/cnstp/api/cnstp-service.ts create mode 100644 apps/as/src/entities/cnstp/index.ts create mode 100644 apps/as/src/entities/cnstp/model/cnstp-interface.ts 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", From d3434060ba686ada877234b959e70fa5fbdf7bdf Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 18:25:50 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[as=20instl=20service]=20=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instl-sncn/api/instl-sncn-service.ts | 9 ++ apps/as/src/entities/instl-sncn/index.ts | 3 + .../instl-sncn/model/instl-sncn-interface.ts | 17 +++ .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 124 +++++++++++++++--- 4 files changed, 138 insertions(+), 15 deletions(-) diff --git a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts index 29d15fcf0..dd7dccd7a 100644 --- a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts +++ b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts @@ -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(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); return response.data; } + + public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise { + const response = await client.post( + `${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`, + payload, + ); + return response.data; + } } export const InstlSncnApi = new InstlSncnApiService(); diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index a85a145b5..5da39b459 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -1,7 +1,10 @@ export { InstlSncnApi } from "./api/instl-sncn-service"; export { INSTL_SNCN_REGISTER_SELECTION_KEY, + type InstlSncnApplySaveRequestDto, + type InstlSncnBizCstSaveRequestDto, type InstlSncnBzScaleSaveDto, + type InstlSncnCklstResultSaveDto, 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 1bf43482f..0209bfef3 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 @@ -80,7 +80,24 @@ export interface InstlSncnBizCstSaveRequestDto { tstmDesignAmt: number; } +/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */ +export interface InstlSncnCklstResultSaveDto { + cklstSn: number; + cklstArtclSn: number; + cklstRvwSn: number; + selected: "Y" | "N" | null; +} + +/** 설치인가 신청 - 통합 저장 요청 DTO */ +export interface InstlSncnApplySaveRequestDto { + instlSncnMng: InstlSncnMng; + bzScaleList: InstlSncnBzScaleSaveDto[]; + bizCstList: InstlSncnBizCstSaveRequestDto[]; + cklstResultList: InstlSncnCklstResultSaveDto[]; +} + export interface InstlSncnMngService { getPage: (params: InstlSncnMngSearchParams) => Promise>; getById: (id: string) => Promise; + saveApply: (payload: InstlSncnApplySaveRequestDto) => 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 128cae223..97fc032e7 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 @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; @@ -13,7 +13,11 @@ 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"; @@ -103,8 +107,9 @@ function CnfmRegistPage() { const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); /* ── 체크리스트 ── */ - type CheckResultVal = "Y" | "N" | ""; + type CheckResultVal = "Y" | "N" | null; const [checkResults, setCheckResults] = useState>({}); + const cklstRowRefs = useRef>({}); const { data: cklstTree = [] } = useQuery({ queryKey: ["cklstTree", "INSTL", taskSeComCd], @@ -159,7 +164,7 @@ function CnfmRegistPage() { const next: Record = {}; cklstRows.forEach((row) => { const key = cklstKey(row); - next[key] = prev[key] ?? ""; + next[key] = prev[key] ?? null; }); return next; }); @@ -181,9 +186,8 @@ function CnfmRegistPage() { cklstSn: row.cklstSn, cklstArtclSn: row.cklstArtclSn, cklstRvwSn: row.cklstRvwSn, - selected: checkResults[cklstKey(row)] ?? "", - })) - .filter((row) => row.selected !== ""); + selected: checkResults[cklstKey(row)] ?? null, + })); /* ── 사업규모 (공종구분 트리 연동) ── */ // cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map @@ -277,6 +281,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) @@ -306,6 +324,7 @@ function CnfmRegistPage() { /* ── 검증 ── */ function fnValidation(mode: "SAVE" | "PRESENTN") { + /* if (!bzNm.trim()) { alert("사업명을 입력하세요."); return false; @@ -322,26 +341,96 @@ 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( + `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; - const payload = { - taskSeComCd, + const payload: InstlSncnApplySaveRequestDto = { + instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장) bzScaleList: toBzScaleSaveDtoList(), + bizCstList: toBizCstSaveDtoList(), cklstResultList: toCklstSaveDtoList(), - }; - void payload; - alert("저장(임시) API 연동 후 progrsSttus=SV 전송",JSON.stringify(payload, null, 2)); + }; + + try { + const issuedDocNo = await InstlSncnApi.saveApply(payload); + alert(`저장되었습니다. 문서번호: ${issuedDocNo}`); + } catch (error) { + console.error("저장 API 호출 실패", error); + alert("저장 중 오류가 발생했습니다."); + } } function handleSubmit() { if (!fnValidation("PRESENTN")) return; const payload = { - taskSeComCd, + instlSncnMng: toInstlSncnMngPayload("ST"), bzScaleList: toBzScaleSaveDtoList(), - cklstResultList: toCklstSaveDtoList(), + bizCstList: toBizCstSaveDtoList(), + cklstRvwRsltList: toCklstSaveDtoList(), }; void payload; alert("제출 API 연동 후 progrsSttus=ST 전송"); @@ -812,7 +901,12 @@ function CnfmRegistPage() { const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`; return ( -
{row.cklstArtclNm} {row.treeLevel > 1 ? "└ " : ""} From 8ab63870d4546040f549ef49e462824ad11aeaad Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 18:30:53 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[as=20instl]=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0:=20=EA=B8=B0=EC=A1=B4=20=ED=82=A4=EC=9D=98?= =?UTF-8?q?=20=EB=B3=80=ED=99=94=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20Maximum=20up?= =?UTF-8?q?date=20depth=20exceeded.=20This=20can=20happen=20when=20a=20com?= =?UTF-8?q?ponent=20calls=20setState=20inside=20useEffect,=20but=20useEffe?= =?UTF-8?q?ct=20either=20doesn't=20have=20a=20dependency=20array,=20or=20o?= =?UTF-8?q?ne=20of=20the=20dependencies=20changes=20on=20every=20render.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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 97fc032e7..b3414a652 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 @@ -162,11 +162,34 @@ function CnfmRegistPage() { useEffect(() => { setCheckResults((prev) => { const next: Record = {}; + let changed = false; + cklstRows.forEach((row) => { const key = cklstKey(row); - next[key] = prev[key] ?? null; + if (Object.prototype.hasOwnProperty.call(prev, key)) { + next[key] = prev[key]; + } else { + next[key] = null; + changed = true; + } }); - return next; + + // 기존 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]); From e3d022fcd73c689fbc8c54471697ed0444964f81 Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 18:58:51 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[as=20instl]=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/package.json | 1 + .../instl-sncn/model/instl-sncn-interface.ts | 7 + .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 166 ++++++++++++++++-- apps/as/src/shared/config/api.ts | 4 + 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/apps/as/package.json b/apps/as/package.json index 0ca386f38..b30e4e45e 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -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": { 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 0209bfef3..63d280d86 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 @@ -88,12 +88,19 @@ export interface InstlSncnCklstResultSaveDto { 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 { 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 b3414a652..e7f2e1261 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 @@ -4,7 +4,10 @@ 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"; @@ -38,6 +41,34 @@ 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: "" }); @@ -106,6 +137,98 @@ function CnfmRegistPage() { const [etcFiles, setEtcFiles] = useState([]); const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); + /* ── 필수 첨부파일 ── */ + const [requiredFiles, setRequiredFiles] = useState>(() => + REQUIRED_ATTACHMENTS.reduce( + (acc, item) => { + acc[item.key] = { + key: item.key, + label: item.label, + atflCd: "", + atflId: "", + fileName: "", + uploading: false, + }; + return acc; + }, + {} as Record, + ), + ); + const requiredFileInputRefs = useRef>({ + applyForm: null, + planGoalYear: null, + adequacyReview: null, + supervisionCost: null, + costBreakdown: null, + designDrawing: null, + etc: null, + }); + + 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) => { + const file = e.target.files?.[0]; + if (!file) return; + uploadRequiredFile(file, key); + e.target.value = ""; + }; + /* ── 체크리스트 ── */ type CheckResultVal = "Y" | "N" | null; const [checkResults, setCheckResults] = useState>({}); @@ -212,6 +335,14 @@ function CnfmRegistPage() { 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>({}); @@ -436,6 +567,7 @@ function CnfmRegistPage() { bzScaleList: toBzScaleSaveDtoList(), bizCstList: toBizCstSaveDtoList(), cklstResultList: toCklstSaveDtoList(), + esntlAtchFileList: toEsntlAtchFileSaveDtoList(), }; try { @@ -966,7 +1098,7 @@ function CnfmRegistPage() { - {/* ── 첨부파일 ── */} + {/* ── 필수 첨부파일 ── */}

첨부파일

@@ -979,17 +1111,9 @@ function CnfmRegistPage() {
{name}
{label} hwp/pdf
@@ -997,9 +1121,25 @@ function CnfmRegistPage() { type="text" placeholder="첨부문서를 올려주세요" className="cnfm-input flex-1" + value={requiredFiles[key]?.uploading ? "업로드 중..." : requiredFiles[key]?.fileName || ""} readOnly /> - + { + requiredFileInputRefs.current[key] = el; + }} + type="file" + className="hidden" + onChange={(e) => handleRequiredFileChange(key, e)} + /> +
+ + + + + - 점검항목 - 주요 검토내용 - 사용 여부 - 구분 + 점검항목 + 주요 검토내용 + 검토 여부 {displayRows.map(({ row, depth }) => { const branchPrefix = formatTreeBranchPrefix(depth); + if (row.useYn === "N") return null; + return ( - - - - {branchPrefix ? ( - {branchPrefix} - ) : null} - {getArtclLabel(row.cklstArtclSn)} - - - {row.rvwCn ?? "—"} - - {row.useYn === "Y" ? "예" : row.useYn === "N" ? "아니오" : "—"} - - - {formatRvwCnLvNo(row.rvwCnLvNo)} - - + + + + {branchPrefix ? ( + {branchPrefix} + ) : null} + {getArtclLabel(row.cklstArtclSn)} + + + {row.rvwCn ?? "—"} + + +
+ + +
+
+ + +
+
+
+
); })}
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx index e44a7743b..1397d79ff 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx @@ -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) { 미리보기 - @@ -248,7 +268,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { ) : isDisabled ? (

- 상단에서 점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다. + 점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다.

) : displayRows.length === 0 ? (

diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx index a7696794f..d248de9a1 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx @@ -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({

{branchPrefix ? ( - {branchPrefix} + {branchPrefix} ) : null}