diff --git a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx index f2e0cd185..ffe6a71a4 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx @@ -1,3 +1,5 @@ import { CnfmRegistPage } from "@/pages/instl-sncn"; -export default CnfmRegistPage; +export default function Page(props: any) { + return ; +} 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 dd7dccd7a..434a77918 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 @@ -6,6 +6,7 @@ import type { InstlSncnMngSearchParams, InstlSncnMngService, SpringPage, + InstlSncnMngDetResponse, } from "../model/instl-sncn-interface"; export class InstlSncnApiService implements InstlSncnMngService { @@ -16,8 +17,8 @@ export class InstlSncnApiService implements InstlSncnMngService { return response.data; } - public async getById(id: string): Promise { - const response = await client.get(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); + public async getById(id: string): Promise { + const response = await client.get(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); return response.data; } 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 6a3d07ff9..b2872fea7 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 @@ -105,8 +105,19 @@ export interface InstlSncnApplySaveRequestDto { esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[]; } +export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; + +export interface InstlSncnMngDetResponse { + detDto: InstlSncnMng; + bizSclList: any[]; + bizCstList: any[]; + cklstResultMap?: Record; + esntlFileList: any[]; + etcFileList: any[]; +} + export interface InstlSncnMngService { getPage: (params: InstlSncnMngSearchParams) => Promise>; - getById: (id: string) => 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 e144f7d17..058bdd368 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 @@ -70,6 +70,56 @@ const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0"); const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString()); const DEFAULT_RVW_STP_COM_CD = "LOCAL"; //검토단계공통코드: RVWSTP ( 상세 코드 : LOCAL.지자체,ENV.환경청,KECO.환경공단) const DEFAULT_SNCN_SE_COM_CD = "CNFM"; +type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; +const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"]; +const ROLE_TO_REVIEW_STEP: Record = { + ROLE_USER05: "LOCAL", + ROLE_USER04: "LOCAL", + ROLE_USER03: "ENV", + ROLE_USER02: "KECO", + ROLE_USER01: "KECO", +}; +const REVIEW_STEP_ROLE_RANK: Record = { + LOCAL: 1, + ENV: 2, + KECO: 3, +}; +const INSTL_PROGRESS_ORDER = [ + "SV", + "ST", + "LE", + "ER", + "EE", + "TE", + "TS", + "TR", + "TP", + "EP", + "TA", + "TB", +]; + +const getProgressStepCount = (prgrsSttsComCd?: string): number => { + const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd ?? "ST"); + if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1; + if (idx <= INSTL_PROGRESS_ORDER.indexOf("TP")) return 2; + return 3; +}; + +const getRoleStepCount = (dtlInstCd?: string): number => { + const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; + return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; +}; + +const normalizeChecklistResultRows = (payload: any): any[] => { + // support new API field `cklstResultMap` while keeping backward-compatibility + const raw = payload?.cklstResultMap ?? payload?.cklstResultList; + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === "object") { + return Object.values(raw).flatMap((rows) => (Array.isArray(rows) ? rows : [])); + } + return []; +}; /* ------------------------------------------------------------------ */ /* 메인 컴포넌트 */ @@ -106,6 +156,35 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { const alert = useAlertStore((state) => state.setMessage); const qc = useQueryClient(); const currentDet = useMemo(() => initialData?.detDto ?? initialData, [initialData]); + const isModifyMode = Boolean(currentDet?.instlSncnDocNo); + const currentPrgrsSttsComCd = (currentDet?.prgrsSttsComCd as string | undefined) ?? "ST"; + const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd); + const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd); + const visibleStepCount = isModifyMode + ? Math.min(progressBasedMaxStepCount, roleBasedMaxStepCount) + : 1; + const visibleReviewStepCodes = useMemo( + () => REVIEW_STEP_CODES.slice(0, visibleStepCount), + [visibleStepCount], + ); + const editableReviewStep = useMemo(() => { + if (!isModifyMode) return "LOCAL" as ReviewStepCode; + const step = userInfo?.dtlInstCd ? ROLE_TO_REVIEW_STEP[userInfo.dtlInstCd] : undefined; + if (!step) return null; + return visibleReviewStepCodes.includes(step) ? step : null; + }, [isModifyMode, userInfo?.dtlInstCd, visibleReviewStepCodes]); + const [selectedReviewStep, setSelectedReviewStep] = useState("LOCAL"); + + useEffect(() => { + if (visibleReviewStepCodes.length === 0) return; + if (editableReviewStep && selectedReviewStep !== editableReviewStep) { + setSelectedReviewStep(editableReviewStep); + return; + } + if (!visibleReviewStepCodes.includes(selectedReviewStep)) { + setSelectedReviewStep(visibleReviewStepCodes[visibleReviewStepCodes.length - 1]); + } + }, [editableReviewStep, selectedReviewStep, visibleReviewStepCodes]); const docId = useMemo(() => { return currentDet?.instlSncnDocNo ?? ""; @@ -569,27 +648,47 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { useEffect(() => { setCheckResults((prev) => { - const next: Record = {}; + const next: Record = { ...prev }; 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; + visibleReviewStepCodes.forEach((stepCode) => { + const key = cklstKey(row, stepCode); + if (!Object.prototype.hasOwnProperty.call(next, key)) { + next[key] = null; + changed = true; + } + }); + }); + + // 행이 없어졌을 때 stale key만 정리 (현재 화면에 노출되는 step 기준) + const liveKeys = new Set(); + cklstRows.forEach((row) => { + visibleReviewStepCodes.forEach((stepCode) => { + liveKeys.add(cklstKey(row, stepCode)); + }); + }); + + Object.keys(next).forEach((key) => { + const keyStep = key.split("_").pop(); + if ( + keyStep && + visibleReviewStepCodes.includes(keyStep as ReviewStepCode) && + !liveKeys.has(key) + ) { + delete next[key]; changed = true; } }); - // 기존 key가 사라졌거나 값이 달라진 경우만 상태 업데이트 if (!changed) { const prevKeys = Object.keys(prev); - if (prevKeys.length !== Object.keys(next).length) { + const nextKeys = Object.keys(next); + if (prevKeys.length !== nextKeys.length) { changed = true; } else { - for (const key of prevKeys) { - if (!(key in next) || prev[key] !== next[key]) { + for (const key of nextKeys) { + if (prev[key] !== next[key]) { changed = true; break; } @@ -599,17 +698,20 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { return changed ? next : prev; }); - }, [cklstRows]); + }, [cklstRows, visibleReviewStepCodes]); // 수정 모드에서 서버의 체크리스트 결과값을 폼 상태로 반영 useEffect(() => { if (!initialData || cklstRows.length === 0) return; - const results = initialData.cklstResultList ?? []; + const results = normalizeChecklistResultRows(initialData); if (!Array.isArray(results) || results.length === 0) return; const lookup = new Map(); + const stepCodesInResult = new Set(); for (const r of results) { - const stepCode = r.rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD; + const stepCode = (r.rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD) as ReviewStepCode; + if (!REVIEW_STEP_CODES.includes(stepCode)) continue; + stepCodesInResult.add(stepCode); const key = `${r.cklstSn}_${r.cklstArtclSn ?? ""}_${r.cklstRvwSn}_${stepCode}`; if (r.rvwRsltAnmlyYn === "Y" || r.rvwRsltAnmlyYn === "N") lookup.set(key, r.rvwRsltAnmlyYn); } @@ -617,37 +719,86 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { setCheckResults((prev) => { const next = { ...prev }; let changed = false; + const targetStepCodes = Array.from( + new Set([...visibleReviewStepCodes, ...stepCodesInResult]), + ); + for (const row of cklstRows) { - const k = cklstKey(row); - const v = lookup.get(k); - if (v && next[k] !== v) { - next[k] = v as CheckResultVal; - changed = true; - } + targetStepCodes.forEach((stepCode) => { + const k = cklstKey(row, stepCode); + const v = lookup.get(k); + if (v && next[k] !== v) { + next[k] = v as CheckResultVal; + changed = true; + } + }); } return changed ? next : prev; }); - }, [initialData, cklstRows]); + }, [initialData, cklstRows, visibleReviewStepCodes]); const cklstStats = useMemo(() => { + if (!selectedReviewStep) { + return { total: 0, completed: 0, percent: 0 }; + } const total = cklstRows.length; const completed = cklstRows.reduce((acc, row) => { - const val = checkResults[cklstKey(row)] ?? ""; + const val = checkResults[cklstKey(row, selectedReviewStep)] ?? ""; return acc + (val ? 1 : 0); }, 0); const percent = total > 0 ? Math.round((completed / total) * 100) : 0; return { total, completed, percent }; - }, [cklstRows, checkResults]); + }, [cklstRows, checkResults, selectedReviewStep]); const toCklstSaveDtoList = () => - cklstRows - .map((row) => ({ + { + const persistedStepCodeSet = new Set(); + normalizeChecklistResultRows(initialData).forEach((r: any) => { + const code = r?.rvwStpComCd as ReviewStepCode | undefined; + if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code); + }); + visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code)); + + return Array.from(persistedStepCodeSet).flatMap((stepCode) => + cklstRows.map((row) => ({ + cklstSn: row.cklstSn, + cklstArtclSn: row.cklstArtclSn, + cklstRvwSn: row.cklstRvwSn, + rvwStpComCd: stepCode, + selected: checkResults[cklstKey(row, stepCode)] ?? null, + })), + ); + }; + + // 맵 형식으로 저장할 경우(업서트 처리용) - 키는 상세 코드(EX: LOCAL.지자체) 포맷 + const REVIEW_STEP_FULL: Record = { + LOCAL: "LOCAL.지자체", + ENV: "ENV.환경청", + KECO: "KECO.환경공단", + }; + + const toCklstSaveDtoMap = () => { + const persistedStepCodeSet = new Set(); + normalizeChecklistResultRows(initialData).forEach((r: any) => { + const code = r?.rvwStpComCd as ReviewStepCode | undefined; + if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code); + }); + visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code)); + + const result: Record = {}; + Array.from(persistedStepCodeSet).forEach((stepCode) => { + const fullKey = REVIEW_STEP_FULL[stepCode]; + result[fullKey] = cklstRows.map((row) => ({ cklstSn: row.cklstSn, cklstArtclSn: row.cklstArtclSn, cklstRvwSn: row.cklstRvwSn, - rvwStpComCd: DEFAULT_RVW_STP_COM_CD, - selected: checkResults[cklstKey(row)] ?? null, + rvwStpComCd: fullKey, + selected: checkResults[cklstKey(row, stepCode)] ?? null, })); + }); + + return result; + }; const toEsntlAtchFileSaveDtoList = () => Object.values(requiredFiles) @@ -811,9 +962,14 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { } - // 체크리스트 사용 항목(Y) 중 미선택 항목이 있으면 진행 불가 + if (!editableReviewStep) { + alert("현재 사용자 권한/진행상태 조합에서 입력 가능한 체크리스트 단계가 없습니다."); + return false; + } + + // 현재 입력 단계의 체크리스트 미선택 항목이 있으면 진행 불가 const hasUncheckedChecklist = cklstRows.some( - (row) => !checkResults[cklstKey(row)], + (row) => !checkResults[cklstKey(row, editableReviewStep)], ); if (hasUncheckedChecklist) { alert("체크리스트를 모두 체크하세요."); @@ -833,10 +989,10 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { // 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함 const invalidYesRow = cklstRows.find( - (row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row)] !== "Y", + (row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row, editableReviewStep)] !== "Y", ); if (invalidYesRow) { - const invalidKey = cklstKey(invalidYesRow); + const invalidKey = cklstKey(invalidYesRow, editableReviewStep); const invalidRowEl = cklstRowRefs.current[invalidKey]; invalidRowEl?.scrollIntoView({ behavior: "smooth", block: "center" }); const yesRadio = invalidRowEl?.querySelector( @@ -887,12 +1043,15 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { async function handleSave() { if (!fnValidation("SAVE")) return; - const payload: InstlSncnApplySaveRequestDto = { + // 저장 시에는 하위 호환성을 위해 기존 배열 필드도 함께 전송하되, + // 추가로 서버에서 upsert 처리할 수 있도록 `cklstResultMap` 형태로도 전송합니다. + const payload: InstlSncnApplySaveRequestDto & { cklstResultMap?: Record } = { instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장) bzScaleList: toBzScaleSaveDtoList(), bizCstList: toBizCstSaveDtoList(), cklstResultList: toCklstSaveDtoList(), esntlAtchFileList: toEsntlAtchFileSaveDtoList(), + cklstResultMap: toCklstSaveDtoMap(), }; try { @@ -1326,8 +1485,35 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { {(cklstRows[0]?.cklstNm ?? taskSeComCdNm) + `(${cklstStats.completed}/${cklstStats.total})`} +
+ {visibleReviewStepCodes.length === 0 ? ( + + 현재 사용자 권한/진행상태 조합에서 표시 가능한 체크리스트 단계가 없습니다. + + ) : ( + visibleReviewStepCodes.map((stepCode) => { + const isSelected = selectedReviewStep === stepCode; + const isEditable = editableReviewStep === stepCode; + return ( + + ); + }) + )} +
- 체크리스트 진행상황 — 총합: {cklstStats.total} · 완료:{" "} + 체크리스트 진행상황 [{selectedReviewStep}] — 총합: {cklstStats.total} · 완료: {" "} {cklstStats.completed} · 진행률: {cklstStats.percent}%
@@ -1345,10 +1531,14 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { + )) + )} +
@@ -296,12 +412,12 @@ function CnfmViewPage({ id }: { id: string }) { - {cklstResultList.length === 0 ? ( + {selectedChecklistRows.length === 0 ? ( ) : ( - cklstResultList.map((row: any) => { + selectedChecklistRows.map((row: any) => { const key = `${row.cklstSn}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "STEP01"}`; const hasResult = row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N"; const showRadios = row.bchckUseYn === "Y" || hasResult;
조회된 체크리스트 항목이 없습니다.