From 6938c1e96f00857f83ba352ebeb7ffaa770c909c Mon Sep 17 00:00:00 2001 From: jangwoong Date: Thu, 21 May 2026 13:13:30 +0900 Subject: [PATCH] =?UTF-8?q?[as=20instl=20service]=20=ED=9A=8C=EA=B3=84=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=97=B0=EA=B2=B0=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/acntg/api/acntg-service.ts | 15 + apps/as/src/entities/acntg/index.ts | 2 + .../entities/acntg/model/acntg-interface.ts | 23 + .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 801 +++++++++++++++++- apps/as/src/shared/config/api.ts | 3 + 5 files changed, 807 insertions(+), 37 deletions(-) create mode 100644 apps/as/src/entities/acntg/api/acntg-service.ts create mode 100644 apps/as/src/entities/acntg/index.ts create mode 100644 apps/as/src/entities/acntg/model/acntg-interface.ts diff --git a/apps/as/src/entities/acntg/api/acntg-service.ts b/apps/as/src/entities/acntg/api/acntg-service.ts new file mode 100644 index 000000000..60690a0b9 --- /dev/null +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -0,0 +1,15 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface"; + +async function findTree(): Promise { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/tree`, + ); + return response.data; +} + +export const acntgService = { + findTree, +} satisfies AcntgService; diff --git a/apps/as/src/entities/acntg/index.ts b/apps/as/src/entities/acntg/index.ts new file mode 100644 index 000000000..7a1562423 --- /dev/null +++ b/apps/as/src/entities/acntg/index.ts @@ -0,0 +1,2 @@ +export type { AcntgSeMngTreeDto, AcntgService } from "./model/acntg-interface"; +export { acntgService } from "./api/acntg-service"; diff --git a/apps/as/src/entities/acntg/model/acntg-interface.ts b/apps/as/src/entities/acntg/model/acntg-interface.ts new file mode 100644 index 000000000..52bae8010 --- /dev/null +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -0,0 +1,23 @@ +/** 회계구분 트리 노드 DTO */ +export interface AcntgSeMngTreeDto { + /** 들여쓰기 포함 표시명 (앞 공백 수 = 레벨 인디케이터) */ + indAcntgSeCdNm: string; + /** 회계구분코드 */ + acntgSeCd: string; + /** 회계구분코드명 */ + acntgSeCdNm: string; + /** 상위 회계구분코드 (null = 최상위) */ + upAcntgSeCd: string | null; + /** 사용여부 */ + useYn: string; + /** 트리 레벨 (1 = 총사업비, 2 = 1.공사비/2.보상비/3.부대경비, …) */ + treeLevel: number; + /** 최상위~현재 노드까지 "|" 구분 경로 */ + treePath: string; +} + +/** 회계구분 서비스 인터페이스 */ +export interface AcntgService { + /** 회계구분 트리 전체 조회 */ + findTree(): 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 0b2af414a..82e41bbfe 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,86 +1,813 @@ "use client"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; + +import { useQuery } from "@tanstack/react-query"; + +import type { AcntgSeMngTreeDto } from "@/entities/acntg"; +import { acntgService } from "@/entities/acntg"; import { Button, Input } from "@common/components"; import "./cnfm-page.css"; +/* ------------------------------------------------------------------ */ +/* 타입 */ +/* ------------------------------------------------------------------ */ +type BzScaleRow = { + bscPl: string; // 예산반영(A) + budgetReflct: string; // 표준사업비(B) + thtiDsgn: string; // 금회설계시(C) +}; + +type BzAmtRow = { + bscPl: string; + budgetReflct: string; + thtiDsgn: string; +}; + +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()); + +/* ------------------------------------------------------------------ */ +/* 메인 컴포넌트 */ +/* ------------------------------------------------------------------ */ function CnfmRegistPage() { const router = useRouter(); + /* ── 기본 정보 ── */ + const [bzNm, setBzNm] = useState(""); + const [bzBgngYr, setBzBgngYr] = useState(""); + const [bzEndYr, setBzEndYr] = useState(""); + const [bzCn, setBzCn] = useState(""); + const [instlCnfmApr, setInstlCnfmApr] = useState("CNFM"); + + /* ── 설계자 정보 ── */ + const [dsgnrNm, setDsgnrNm] = useState(""); + const [dsgnrPsitn, setDsgnrPsitn] = useState(""); + const [dsgnrTelno, setDsgnrTelno] = useState(""); + const [dsgnrEmlAddr, setDsgnrEmlAddr] = useState(""); + + /* ── 개인정보 동의 ── */ + const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N"); + + /* ── 기타 첨부파일 ── */ + type EtcFile = { id: number; name: string; ext: string; size: string; uploader: string }; + const [etcFiles, setEtcFiles] = useState([]); + const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); + + /* ── 체크리스트 ── */ + const CHECKLIST_ITEMS = [ + "사업계획의 적정성", + "사업규모의 적정성", + "사업방식의 적정성", + "사업비의 적정성", + "사업효과의 적정성", + "사업일정의 적정성", + ]; + const [checkResults, setCheckResults] = useState>(() => + Object.fromEntries(CHECKLIST_ITEMS.map((item) => [item, "" as "Y" | "N" | ""])), + ); + + /* ── 사업규모 ── */ + 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()); // 배수설비 + + /* ── 총사업비 (회계구분 트리 연동) ── */ + // acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map + const [amtMap, setAmtMap] = useState>({}); + + /* ── 회계구분 트리 로드 ── */ + const { data: acntgTree = [] } = useQuery({ + queryKey: ["acntgTree"], + queryFn: acntgService.findTree, + staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요 + }); + + /* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */ + const leafSet = useMemo(() => { + const parentCodes = new Set(acntgTree.map((n) => n.upAcntgSeCd).filter(Boolean) as string[]); + return new Set(acntgTree.filter((n) => !parentCodes.has(n.acntgSeCd)).map((n) => n.acntgSeCd)); + }, [acntgTree]); + + /** + * treePath 기반 소계 계산 + * - 이 노드의 acntgSeCd 가 treePath 안에 포함된 리프 노드들의 합산 + * - 새로운 하위 레벨이 추가돼도 자동으로 포함됨 + */ + const subtotal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number => + acntgTree + .filter((n) => leafSet.has(n.acntgSeCd) && n.treePath.split("|").includes(node.acntgSeCd)) + .reduce((sum, n) => sum + num((amtMap[n.acntgSeCd] ?? emptyAmt())[col]), 0); + + /** 노드 값 반환 — 리프이면 입력값, 부모이면 소계 */ + const nodeVal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number => + leafSet.has(node.acntgSeCd) + ? num((amtMap[node.acntgSeCd] ?? emptyAmt())[col]) + : subtotal(node, col); + + /** 단일 셀 입력 핸들러 */ + const handleAmtInput = (acntgSeCd: string, col: keyof BzAmtRow, val: string) => + setAmtMap((prev) => ({ + ...prev, + [acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val }, + })); + + /** + * indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환 + * (공백 4칸 = 1 depth = 1rem) + */ + const indentPx = (indNm: string): number => { + const spaces = indNm.length - indNm.trimStart().length; + return (spaces / 4) * 16; // 4공백 = 16px + }; + + /** 트리 행 스타일 분류 */ + const rowStyle = (node: AcntgSeMngTreeDto) => { + if (node.treeLevel === 1) return "bg-blue-50 font-bold"; + if (node.treeLevel === 2) return "bg-gray-100 font-semibold"; + if (!leafSet.has(node.acntgSeCd)) 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; + } + if (!bzBgngYr || !bzEndYr) { + alert("사업기간을 입력하세요."); + return false; + } + if (!bzCn.trim()) { + alert("사업내용을 입력하세요."); + return false; + } + if (mode === "PRESENTN" && privacyAgree !== "Y") { + alert("개인정보 제공에 동의하셔야 제출이 가능합니다."); + return false; + } + return true; + } + + function handleSave() { + if (!fnValidation("SAVE")) return; + alert("저장(임시) API 연동 후 progrsSttus=SV 전송"); + } + + function handleSubmit() { + if (!fnValidation("PRESENTN")) return; + alert("제출 API 연동 후 progrsSttus=ST 전송"); + } + + /* ── 공통 입력 핸들러 ── */ + function scaleInput( + setter: React.Dispatch>, + col: keyof BzScaleRow, + val: string, + ) { + 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 (
+ {/* ── 프로세스 스텝 바 ── */}
-

설치인가 신청

-

- AS-IS 화면 설계(신청 화면)를 반영한 입력 레이아웃입니다. -

+
+ {["설치인가 신청", "기술검토/조치계획 검토", "기술검토 회신", "인가승인"].map( + (label, i) => ( + + + + {label} + + {i < 3 && ›} + + ), + )} +
+ {/* ── 타이틀 + 액션 버튼 ── */} +
+

■ 설치인가 신청/수정

+
+ + +
+
+ + {/* ── 기본 정보 ── */}
-

기본 정보

- +

기본 정보

+
- - + + + + + + + + +
사업명 - + + * 사업명 + +
+ setBzNm(e.target.value)} + /> + +
+ * 사업기간 + +
+ setBzBgngYr(e.target.value)} + /> + ~ + setBzEndYr(e.target.value)} + /> +
+
+ * 사업내용 + +