From dd4ee1dcb55c0b2e6464c8945f204a77b6fd347e Mon Sep 17 00:00:00 2001 From: jangwoong Date: Wed, 14 Oct 2026 18:00:37 +0900 Subject: [PATCH] =?UTF-8?q?[as=20elapr]=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=8F=99=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B2=B0?= =?UTF-8?q?=EC=9E=AC=EC=9E=90=20=EC=88=AB=EC=9E=90=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=B0=8F=20=EC=B2=B4=ED=81=AC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?SP,=20SF=20=ED=95=84=EC=88=98=20=EB=A1=9C=EC=A7=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../elapr/lib/doc-prgrs-to-forkjoin-rows.ts | 34 +++--- .../elapr/model/elapr-apln-row-types.ts | 3 +- .../elapr/model/elapr-forkjoin-types.ts | 10 +- .../elapr/ui/elapr-approval-dialog.tsx | 10 +- ...elapr-doc-prgrs-forkjoin-picker-dialog.tsx | 4 +- .../ui/elapr-doc-prgrs-forkjoin-step.tsx | 32 ++++-- .../elapr/ui/elapr-doc-prgrs-status.tsx | 4 +- .../elapr/ui/elapr-forkjoin-picker-grid.tsx | 101 ++++++++++++----- .../elapr/ui/elapr-tmplt-picker-dialog.tsx | 4 +- .../pages/fnrs-cnsltn/lib/checklist-utils.ts | 106 ++++++++++++++++++ .../ui/components/floating-action-buttons.tsx | 46 ++++++-- 11 files changed, 273 insertions(+), 81 deletions(-) diff --git a/apps/as/src/entities/elapr/lib/doc-prgrs-to-forkjoin-rows.ts b/apps/as/src/entities/elapr/lib/doc-prgrs-to-forkjoin-rows.ts index 3a0f4f39a..e1ee0bb8e 100644 --- a/apps/as/src/entities/elapr/lib/doc-prgrs-to-forkjoin-rows.ts +++ b/apps/as/src/entities/elapr/lib/doc-prgrs-to-forkjoin-rows.ts @@ -2,7 +2,7 @@ import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model"; import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -export type ForkJoinTrackKey = "PIPE" | "FACILITY" | "TOTAL_AGG"; +export type ForkJoinTrackKey = "SP" | "SF" | "TOTAL_AGG"; function normalizeBranchKey( rawBranchSeCd?: string, @@ -10,8 +10,8 @@ function normalizeBranchKey( ): ForkJoinTrackKey | null { if (rawBranchSeCd) { const upper = rawBranchSeCd.toUpperCase().trim(); - if (upper.includes("PIPE") || upper.includes("관로")) return "PIPE"; - if (upper.includes("FACIL") || upper.includes("시설")) return "FACILITY"; + if (upper === "SP" || upper.includes("관로")) return "SP"; + if (upper === "SF" || upper.includes("시설")) return "SF"; if (upper.includes("AGG") || upper.includes("정책") || upper.includes("TOTAL")) return "TOTAL_AGG"; } @@ -45,8 +45,8 @@ export function mapDocPrgrsToForkJoinTrackRows( list: ElaprDocPrgrsClientResponseDto[] | undefined, ): Record { const tracks: Record = { - PIPE: [], - FACILITY: [], + SP: [], + SF: [], TOTAL_AGG: [], }; @@ -56,9 +56,9 @@ export function mapDocPrgrsToForkJoinTrackRows( if (branchKey) { const seq = Number(item.atrzSeqNo ?? tracks[branchKey].length + 1); const defaultDept = - branchKey === "PIPE" + branchKey === "SP" ? "하수관로부" - : branchKey === "FACILITY" + : branchKey === "SF" ? "하수처리시설부" : "정책총괄부"; @@ -91,22 +91,22 @@ export function mapDocPrgrsToForkJoinTrackRows( }); // 각 트랙별 atrzSeqNo 오름차순 정렬 - tracks.PIPE.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); - tracks.FACILITY.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); + tracks.SP.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); + tracks.SF.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); tracks.TOTAL_AGG.sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); } // 트랙 데이터가 없는 경우 기본 2행 채우기 - if (tracks.PIPE.length === 0) { - tracks.PIPE = [ - createDefaultForkJoinRow("PIPE", "하수관로부", 1, "ATRZ02"), - createDefaultForkJoinRow("PIPE", "하수관로부", 2, "ATRZ03"), + if (tracks.SP.length === 0) { + tracks.SP = [ + createDefaultForkJoinRow("SP", "하수관로부", 1, "ATRZ02"), + createDefaultForkJoinRow("SP", "하수관로부", 2, "ATRZ03"), ]; } - if (tracks.FACILITY.length === 0) { - tracks.FACILITY = [ - createDefaultForkJoinRow("FACILITY", "하수처리시설부", 1, "ATRZ02"), - createDefaultForkJoinRow("FACILITY", "하수처리시설부", 2, "ATRZ03"), + if (tracks.SF.length === 0) { + tracks.SF = [ + createDefaultForkJoinRow("SF", "하수처리시설부", 1, "ATRZ02"), + createDefaultForkJoinRow("SF", "하수처리시설부", 2, "ATRZ03"), ]; } if (tracks.TOTAL_AGG.length === 0) { diff --git a/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts index 378c6c5e1..05c89d151 100644 --- a/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts @@ -1,3 +1,4 @@ +import type { BranchSeCd } from "./elapr-forkjoin-types"; import type { ElaprApln } from "./elapr-apln-interface"; /** 그리드 로컬 행 — API 응답 + UI 보조 필드 */ @@ -9,7 +10,7 @@ export interface ElaprAplnRow extends Omit { aprvrNm?: string; /** 소속 브랜치 ID 및 구분코드 */ branchId?: string; - branchSeCd?: "MAIN" | "PIPE" | "FACILITY" | "TOTAL_AGG"; + branchSeCd?: BranchSeCd; aprvrDeptNm?: string; dcarbYn?: string; isNew?: boolean; diff --git a/apps/as/src/entities/elapr/model/elapr-forkjoin-types.ts b/apps/as/src/entities/elapr/model/elapr-forkjoin-types.ts index 584a28c43..64aaaa61c 100644 --- a/apps/as/src/entities/elapr/model/elapr-forkjoin-types.ts +++ b/apps/as/src/entities/elapr/model/elapr-forkjoin-types.ts @@ -19,8 +19,8 @@ export type AtrzSeComCd = | "ATRZ04" // 병합 (정책총괄 AI 병합의견 종합 심의 - 10-12 신설) | "ATRZ99"; // 최종승인(회신) (환경청에 회신) -/** 브랜치 구분코드 */ -export type BranchSeCd = "MAIN" | "PIPE" | "FACILITY" | "TOTAL_AGG"; +/** 브랜치 구분코드 (10월 14일 개정: SP 하수관로, SF 하수처리시설, TOTAL_AGG 정책부 종합병합, MAIN 주결재선) */ +export type BranchSeCd = "MAIN" | "SP" | "SF" | "TOTAL_AGG"; /** 브랜치 진행상태코드 */ export type BranchSttsCd = @@ -65,10 +65,10 @@ export interface ElaprForkJoinStepDto { elaprCycl?: number; } -/** 3개 트랙으로 분할된 결재선 데이터 맵 (10-12 개정: 총 6인 체제) */ +/** 3개 트랙으로 분할된 결재선 데이터 맵 (총 6인 체제: SP, SF, TOTAL_AGG) */ export interface ElaprForkJoinDataMap { - pipeTrack: ElaprForkJoinStepDto[]; // 관로부 (2명: 검토(02) ➔ 승인(03)) - facilityTrack: ElaprForkJoinStepDto[]; // 시설부 (2명: 검토(02) ➔ 승인(03)) + spTrack: ElaprForkJoinStepDto[]; // 관로부 (2명: 검토(02) ➔ 승인(03)) + sfTrack: ElaprForkJoinStepDto[]; // 시설부 (2명: 검토(02) ➔ 승인(03)) aggTrack: ElaprForkJoinStepDto[]; // 정책부 종합병합 (2명: 병합(04) ➔ 최종승인(99)) } diff --git a/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx b/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx index 9e8751b63..e1613e7e9 100644 --- a/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx +++ b/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx @@ -114,8 +114,8 @@ function ElaprApprovalDialog({ const branchSeCd = (currentUserPrgrs as { branchSeCd?: string } | undefined)?.branchSeCd; const isForkJoin = taskSeComCd === "SF_SP" || - branchSeCd === "PIPE" || - branchSeCd === "FACILITY" || + branchSeCd === "SP" || + branchSeCd === "SF" || branchSeCd === "TOTAL_AGG"; const isLastInBranch = @@ -125,7 +125,7 @@ function ElaprApprovalDialog({ if (!isForkJoin) return "결재"; if (branchSeCd === "TOTAL_AGG" && isLastInBranch) return "결재 (환경청에 회신)"; if (branchSeCd === "TOTAL_AGG") return "결재 (병합)"; - if ((branchSeCd === "PIPE" || branchSeCd === "FACILITY") && isLastInBranch) + if ((branchSeCd === "SP" || branchSeCd === "SF") && isLastInBranch) return "결재 (병합에 회신)"; return "결재 (검토)"; }, [isForkJoin, branchSeCd, isLastInBranch]); @@ -304,9 +304,9 @@ function ElaprApprovalDialog({
- {branchSeCd === "PIPE" + {branchSeCd === "SP" ? "🌊 하수관로 전문기술검토 (Stage 2)" - : branchSeCd === "FACILITY" + : branchSeCd === "SF" ? "🏭 하수처리시설 전문기술검토 (Stage 2)" : branchSeCd === "TOTAL_AGG" ? isLastInBranch diff --git a/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-picker-dialog.tsx b/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-picker-dialog.tsx index d6914e00a..d83774c72 100644 --- a/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-picker-dialog.tsx +++ b/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-picker-dialog.tsx @@ -81,10 +81,10 @@ export function ElaprDocPrgrsForkJoinPickerDialog({ } const pipeRows = aplnRows - .filter((r) => r.branchSeCd === "PIPE") + .filter((r) => r.branchSeCd === "SP") .sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); const facRows = aplnRows - .filter((r) => r.branchSeCd === "FACILITY") + .filter((r) => r.branchSeCd === "SF") .sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); const aggRows = aplnRows .filter((r) => r.branchSeCd === "TOTAL_AGG") diff --git a/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-step.tsx b/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-step.tsx index 7620d008f..5b33746e4 100644 --- a/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-step.tsx +++ b/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-step.tsx @@ -74,9 +74,9 @@ export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepPr steps.forEach((step) => { const seCd = step.branchSeCd; - if (seCd === "PIPE") { + if (seCd === "SP") { pipe.push(step); - } else if (seCd === "FACILITY" ) { + } else if (seCd === "SF") { fac.push(step); } else if (seCd === "TOTAL_AGG") { agg.push(step); @@ -96,15 +96,21 @@ export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepPr }; }, [steps]); - // 완료 상태 판정 + // 완료 상태 및 결재 완료 수 동적 판정 + const pipeCompletedCount = pipeSteps.filter((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003").length; const isPipeCompleted = - pipeSteps.length > 0 && - pipeSteps.every((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003"); + pipeSteps.length > 0 && pipeCompletedCount === pipeSteps.length; + + const facCompletedCount = facSteps.filter((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003").length; const isFacCompleted = - facSteps.length > 0 && - facSteps.every((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003"); + facSteps.length > 0 && facCompletedCount === facSteps.length; const isJoinReady = isPipeCompleted && isFacCompleted; + const stage2CountText = + pipeSteps.length === facSteps.length + ? `(각 ${pipeSteps.length}인)` + : `(관로 ${pipeSteps.length}인 / 시설 ${facSteps.length}인)`; + return (
@@ -113,7 +119,7 @@ export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepPr

- Stage 2: 부서별 병렬 전문검토 (각 2인) + Stage 2: 부서별 병렬 전문검토 {stage2CountText}

동시 진행 @@ -131,7 +137,9 @@ export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepPr : "bg-cyan-100 text-cyan-800" }`} > - {isPipeCompleted ? "병합에 회신 완료 (2/2)" : "진행중"} + {isPipeCompleted + ? `병합에 회신 완료 (${pipeCompletedCount}/${pipeSteps.length})` + : `진행중 (${pipeCompletedCount}/${pipeSteps.length})`}
@@ -157,7 +165,9 @@ export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepPr : "bg-indigo-100 text-indigo-800" }`} > - {isFacCompleted ? "병합에 회신 완료 (2/2)" : "진행중"} + {isFacCompleted + ? `병합에 회신 완료 (${facCompletedCount}/${facSteps.length})` + : `진행중 (${facCompletedCount}/${facSteps.length})`}
@@ -205,7 +215,7 @@ export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepPr

- Stage 3: 정책총괄 종합병합 및 환경청 회신 (2인) + Stage 3: 정책총괄 종합병합 및 환경청 회신 ({aggSteps.length}인)

- (s as { branchSeCd?: string }).branchSeCd === "PIPE" || - (s as { branchSeCd?: string }).branchSeCd === "FACILITY", + (s as { branchSeCd?: string }).branchSeCd === "SP" || + (s as { branchSeCd?: string }).branchSeCd === "SF", ) || list.length >= 6; diff --git a/apps/as/src/entities/elapr/ui/elapr-forkjoin-picker-grid.tsx b/apps/as/src/entities/elapr/ui/elapr-forkjoin-picker-grid.tsx index 0b7679b90..677ffa2c6 100644 --- a/apps/as/src/entities/elapr/ui/elapr-forkjoin-picker-grid.tsx +++ b/apps/as/src/entities/elapr/ui/elapr-forkjoin-picker-grid.tsx @@ -26,7 +26,7 @@ import type { ReviewerOption } from "../model/reviewer-option"; import { AprvrPickerDialog } from "./aprvr-picker-dialog"; import { SortableAplnPickerRow } from "./sortable-apln-picker-row"; -type ForkJoinTrackKey = "PIPE" | "FACILITY" | "TOTAL_AGG"; +type ForkJoinTrackKey = "SP" | "SF" | "TOTAL_AGG"; interface TrackConfig { key: ForkJoinTrackKey; @@ -39,7 +39,7 @@ interface TrackConfig { const TRACK_CONFIGS: TrackConfig[] = [ { - key: "PIPE", + key: "SP", label: "하수관로 검토", defaultDeptNm: "하수관로부", guideText: "ℹ️ 하수관로 부서 검토·승인 완료 시 종합병합부서로 내부 회신(이관)됩니다.", @@ -47,7 +47,7 @@ const TRACK_CONFIGS: TrackConfig[] = [ maxCount: 5, }, { - key: "FACILITY", + key: "SF", label: "하수처리시설 검토", defaultDeptNm: "하수처리시설부", guideText: "ℹ️ 하수처리시설 부서 검토·승인 완료 시 종합병합부서로 내부 회신(이관)됩니다.", @@ -83,44 +83,80 @@ function createDefaultRow( }; } +export function createDefaultTrackRows( + perTrackCount: number = 2, +): Record { + const safeCount = Math.max(2, Math.min(5, perTrackCount)); + const sp: ElaprAplnRow[] = []; + const sf: ElaprAplnRow[] = []; + const totalAgg: ElaprAplnRow[] = []; + + for (let seq = 1; seq <= safeCount; seq++) { + sp.push( + createDefaultRow( + "SP", + "하수관로부", + seq, + seq === 1 ? "ATRZ02" : "ATRZ03", + ), + ); + sf.push( + createDefaultRow( + "SF", + "하수처리시설부", + seq, + seq === 1 ? "ATRZ02" : "ATRZ03", + ), + ); + totalAgg.push( + createDefaultRow( + "TOTAL_AGG", + "정책총괄부", + seq, + seq === 1 ? "ATRZ04" : seq === safeCount ? "ATRZ99" : "ATRZ03", + ), + ); + } + + return { SP: sp, SF: sf, TOTAL_AGG: totalAgg }; +} + export interface ElaprForkJoinPickerGridProps { onRowsChange?: (rows: ElaprAplnRow[]) => void; onEditingLockedChange?: (locked: boolean) => void; initialTrackRows?: Record | null; + /** 트랙당 기본 초기 결재자 수 (기본 2명, 필요 시 3명 지원) */ + defaultPerTrack?: number; } export function ElaprForkJoinPickerGrid({ onRowsChange, onEditingLockedChange, initialTrackRows, + defaultPerTrack = 2, }: ElaprForkJoinPickerGridProps) { const alert = useAlertStore((state) => state.setMessage); - const [activeTab, setActiveTab] = useState("PIPE"); + const [activeTab, setActiveTab] = useState("SP"); - // 3개 트랙별 결재선 상태 (10월 12일 개정: 각 2명씩 총 6인 체제) + // 3개 트랙별 결재선 상태 (기본 2인 또는 3인 초기화 지원) const [trackRows, setTrackRows] = useState>(() => { if (initialTrackRows) { - return initialTrackRows; + return { + SP: initialTrackRows.SP ?? [], + SF: initialTrackRows.SF ?? [], + TOTAL_AGG: initialTrackRows.TOTAL_AGG ?? [], + }; } - return { - PIPE: [ - createDefaultRow("PIPE", "하수관로부", 1, "ATRZ02"), // 검토 - createDefaultRow("PIPE", "하수관로부", 2, "ATRZ03"), // 승인 (병합에 회신) - ], - FACILITY: [ - createDefaultRow("FACILITY", "하수처리시설부", 1, "ATRZ02"), // 검토 - createDefaultRow("FACILITY", "하수처리시설부", 2, "ATRZ03"), // 승인 (병합에 회신) - ], - TOTAL_AGG: [ - createDefaultRow("TOTAL_AGG", "정책총괄부", 1, "ATRZ04"), // 병합 (10-12 신설) - createDefaultRow("TOTAL_AGG", "정책총괄부", 2, "ATRZ99"), // 최종승인/전결 (환경청 회신) - ], - }; + return createDefaultTrackRows(defaultPerTrack); }); useEffect(() => { if (initialTrackRows) { - setTrackRows(initialTrackRows); + setTrackRows({ + SP: initialTrackRows.SP ?? [], + SF: initialTrackRows.SF ?? [], + TOTAL_AGG: initialTrackRows.TOTAL_AGG ?? [], + }); } }, [initialTrackRows]); @@ -132,7 +168,7 @@ export function ElaprForkJoinPickerGrid({ // 통합 전체 결재선 목록 const allRows = useMemo(() => { - return [...trackRows.PIPE, ...trackRows.FACILITY, ...trackRows.TOTAL_AGG]; + return [...(trackRows.SP ?? []), ...(trackRows.SF ?? []), ...(trackRows.TOTAL_AGG ?? [])]; }, [trackRows]); useEffect(() => { @@ -178,10 +214,23 @@ export function ElaprForkJoinPickerGrid({ newSeq, defaultSeComCd, ); - setTrackRows((prev) => ({ - ...prev, - [activeTab]: [...prev[activeTab], newRow], - })); + setTrackRows((prev) => { + const list = [...prev[activeTab]]; + if (activeTab === "TOTAL_AGG" && list.length > 0) { + const lastIdx = list.length - 1; + if (list[lastIdx].atrzSeComCd === "ATRZ99") { + list[lastIdx] = { + ...list[lastIdx], + atrzSeComCd: "ATRZ03", + dcarbYn: "N", + }; + } + } + return { + ...prev, + [activeTab]: [...list, newRow], + }; + }); setEditingRowKey(newRow.rowKey); }, [activeTab, currentRows.length, currentTrackConfig, editingRowKey, alert]); diff --git a/apps/as/src/entities/elapr/ui/elapr-tmplt-picker-dialog.tsx b/apps/as/src/entities/elapr/ui/elapr-tmplt-picker-dialog.tsx index b446c9336..cbfd53e2d 100644 --- a/apps/as/src/entities/elapr/ui/elapr-tmplt-picker-dialog.tsx +++ b/apps/as/src/entities/elapr/ui/elapr-tmplt-picker-dialog.tsx @@ -108,10 +108,10 @@ function ElaprTmpltPickerDialog({ } const pipeRows = aplnRows - .filter((r) => r.branchSeCd === "PIPE") + .filter((r) => r.branchSeCd === "SP") .sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); const facRows = aplnRows - .filter((r) => r.branchSeCd === "FACILITY") + .filter((r) => r.branchSeCd === "SF") .sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); const aggRows = aplnRows .filter((r) => r.branchSeCd === "TOTAL_AGG") diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts index 4ae0ee1f9..653b795cf 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts @@ -1,3 +1,4 @@ +import type { BranchSeCd } from "@/entities/elapr"; import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn"; import { @@ -142,3 +143,108 @@ export function toCklstSaveDto( }; }); } + +/** + * 3. 결재자 branch_se_cd 기반 체크리스트 독립 Validation 유틸리티 (10월 14일 개정) + * 결재자의 branch_se_cd('SP', 'SF', 'TOTAL_AGG', 'MAIN')에 따라 해당 탭 항목만 검증합니다. + */ +export function validateChecklistByBranch( + checklistItems: { + taskSeComCd: string; + bchckUseYn: string; + selected?: string; // 'Y' | 'N' + picRvwCn?: string; // 검토자의견 + cklstRvwItemNm: string; + cklstRvwSn: number; + }[], + approverBranchSeCd: BranchSeCd, + docTaskSeComCd: string, +): { isValid: boolean; errorMessage?: string; errorItemSn?: number } { + // TOTAL_AGG(종합병합): 관로/시설 병렬 검토 완료 후 총괄취합 단계이므로 체크리스트 검증 생략 + if (approverBranchSeCd === "TOTAL_AGG") { + return { isValid: true }; + } + + let targetTaskCodes: string[] = []; + if (approverBranchSeCd === "SP") { + targetTaskCodes = ["SP"]; // 관로부: SP 탭만 검증 (시설부 미작성 무관) + } else if (approverBranchSeCd === "SF") { + targetTaskCodes = ["SF"]; // 시설부: SF 탭만 검증 (관로부 미작성 무관) + } else { + // 단일/순차 결재 (MAIN 등): 콤마 분리, 또는 "MAIN"/누락 시 체크리스트 실제 공종 전체 자동 추출 + const parsedCodes = + docTaskSeComCd && docTaskSeComCd !== "MAIN" + ? docTaskSeComCd.split(",").map((s) => s.trim()).filter(Boolean) + : []; + + targetTaskCodes = + parsedCodes.length > 0 + ? parsedCodes + : Array.from(new Set(checklistItems.map((i) => i.taskSeComCd).filter(Boolean))); + } + + const activeItems = checklistItems.filter( + (item) => targetTaskCodes.includes(item.taskSeComCd) && item.bchckUseYn === "Y", + ); + + for (const item of activeItems) { + const taskName = + item.taskSeComCd === "SP" + ? "하수관로" + : item.taskSeComCd === "RE" + ? "재이용수" + : "하수처리시설"; + + // 1. 적합/부적합 미선택 검증 + if (!item.selected || !["Y", "N"].includes(item.selected)) { + return { + isValid: false, + errorMessage: `[${taskName}] 체크리스트 '${item.cklstRvwItemNm}' 항목의 적합 여부를 선택해주세요.`, + errorItemSn: item.cklstRvwSn, + }; + } + // 2. 부적합('N') 시 검토자 검토의견 필수 검증 + if (item.selected === "N" && (!item.picRvwCn || item.picRvwCn.trim() === "")) { + return { + isValid: false, + errorMessage: `[${taskName}] 체크리스트 '${item.cklstRvwItemNm}' 항목이 '부적합'입니다. 검토자 검토의견을 반드시 입력해주세요.`, + errorItemSn: item.cklstRvwSn, + }; + } + } + + return { isValid: true }; +} + +/** + * ChecklistFormState와 CklstRvwRsltTreeDto[]를 받아 결재자 브랜치별 체크리스트 검증을 실행하는 헬퍼 + */ +export function validateChecklistFormStateByBranch({ + rows, + formState, + reviewStep = "KECO", + approverBranchSeCd, + docTaskSeComCd, +}: { + rows: CklstRvwRsltTreeDto[]; + formState: ChecklistFormState; + reviewStep?: ReviewStepCode; + approverBranchSeCd: BranchSeCd; + docTaskSeComCd: string; +}): { isValid: boolean; errorMessage?: string; errorItemSn?: number } { + const displayRows = getChecklistDisplayRows(rows); + const checklistItems = displayRows.map((row) => { + const key = buildChecklistRowKey(row, reviewStep); + return { + taskSeComCd: row.taskSeComCd, + bchckUseYn: row.bchckUseYn, + selected: formState.results[key] ?? (row.rvwRsltAnmlyYn as string | undefined), + picRvwCn: formState.comments[key] ?? row.picRvwCn, + cklstRvwItemNm: row.cklstArtclNm ?? "검토항목", + cklstRvwSn: row.cklstRvwSn, + }; + }); + + return validateChecklistByBranch(checklistItems, approverBranchSeCd, docTaskSeComCd); +} + diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx index b5dbcb10f..3de442ffd 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx @@ -56,7 +56,11 @@ import { getButtonConfigs, needsRoleUser01AtrzResolve, } from "../../lib/button-config"; -import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils"; +import type { BranchSeCd } from "@/entities/elapr"; +import { + computeReviewStepChecklistProgress, + validateChecklistFormStateByBranch, +} from "../../lib/checklist-utils"; import { buildFileSplmntReqPayload, buildRvwRsltSplmntReqPayload, @@ -536,16 +540,38 @@ function FloatingActionButtons({ techReviewOpinionRef?.current?.resetForm(); } + function handleApprovalOpen() { + if (prgrsSttsComCd === "TE" && userPendingBranchSeCd) { + const branchCd = (userPendingBranchSeCd as BranchSeCd) || (isForkJoin ? "SP" : "MAIN"); + const validation = validateChecklistFormStateByBranch({ + rows: data.cklstResultList ?? [], + formState: checklistFormState, + reviewStep: "KECO", + approverBranchSeCd: branchCd, + docTaskSeComCd: normalizedTaskSeComCd || data.detDto?.taskSeComCd || "MAIN", + }); + + if (!validation.isValid) { + alert(validation.errorMessage || "체크리스트 항목을 모두 검토해야 결재가 가능합니다."); + return; + } + } + setActiveDialog("elaprApproval"); + } + async function handleRvwCmptnClick() { if (prgrsSttsComCd === "TE") { - const { percent } = computeReviewStepChecklistProgress( - data.cklstResultList ?? [], - checklistFormState.results, - "KECO", - ); + const branchCd = (userPendingBranchSeCd as BranchSeCd) || (isForkJoin ? "SP" : "MAIN"); + const validation = validateChecklistFormStateByBranch({ + rows: data.cklstResultList ?? [], + formState: checklistFormState, + reviewStep: "KECO", + approverBranchSeCd: branchCd, + docTaskSeComCd: normalizedTaskSeComCd || data.detDto?.taskSeComCd || "MAIN", + }); - if (percent !== 100) { - alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다."); + if (!validation.isValid) { + alert(validation.errorMessage || "모든 체크리스트 항목을 체크해야 검토완료가 가능합니다."); return; } } @@ -818,7 +844,7 @@ function FloatingActionButtons({ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (!isForkJoin || (userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ04")) && ( - )} @@ -832,7 +858,7 @@ function FloatingActionButtons({ ? "green" : undefined } - onClick={() => setActiveDialog("elaprApproval")} + onClick={handleApprovalOpen} disabled={isBusy} > {isForkJoin