From 6c71bccc279f24b1a8b6baf7c30d29bba367aaae Mon Sep 17 00:00:00 2001 From: jangwoong Date: Tue, 13 Oct 2026 18:21:08 +0900 Subject: [PATCH] =?UTF-8?q?[as=20elapr]=20=EC=9E=AC=EC=9B=90=EC=97=85?= =?UTF-8?q?=EB=AC=B4=20=ED=98=91=EC=9D=98=202=EC=B0=A8=20=EA=B2=B0?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EA=B2=80=ED=86=A0?= =?UTF-8?q?=EC=9E=90=20=EC=A7=80=EC=A0=95=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/src/entities/elapr/index.ts | 9 + .../elapr/lib/doc-prgrs-to-forkjoin-rows.ts | 120 +++++++++++ ...elapr-doc-prgrs-forkjoin-picker-dialog.tsx | 190 ++++++++++++++++++ .../elapr/ui/elapr-forkjoin-picker-grid.tsx | 51 +++-- .../pages/fnrs-cnsltn/lib/button-config.ts | 2 - .../ui/components/floating-action-buttons.tsx | 80 +++++--- apps/as/src/shared/model/pbadms-sprt.ts | 1 + 7 files changed, 399 insertions(+), 54 deletions(-) create mode 100644 apps/as/src/entities/elapr/lib/doc-prgrs-to-forkjoin-rows.ts create mode 100644 apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-picker-dialog.tsx diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index e08a68366..59cc4e50c 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -59,6 +59,11 @@ export { buildElaprPrgrsSttsListFromAplnRows, } from "./lib/dmnd-save-utils"; export { mapDocPrgrsToAplnRows } from "./lib/doc-prgrs-to-apln-rows"; +export { + type ForkJoinTrackKey, + createDefaultForkJoinRow, + mapDocPrgrsToForkJoinTrackRows, +} from "./lib/doc-prgrs-to-forkjoin-rows"; export type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils"; export { assignPrgrsSttsAtrzSeqNumbers, @@ -128,6 +133,10 @@ export { type ElaprDocPrgrsPickerConfirmPayload, ElaprDocPrgrsPickerDialog, } from "./ui/elapr-doc-prgrs-picker-dialog"; +export { + type ElaprDocPrgrsForkJoinPickerConfirmPayload, + ElaprDocPrgrsForkJoinPickerDialog, +} from "./ui/elapr-doc-prgrs-forkjoin-picker-dialog"; export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status"; export { ElaprForkJoinPickerGrid } from "./ui/elapr-forkjoin-picker-grid"; export { 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 new file mode 100644 index 000000000..3a0f4f39a --- /dev/null +++ b/apps/as/src/entities/elapr/lib/doc-prgrs-to-forkjoin-rows.ts @@ -0,0 +1,120 @@ +import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model"; + +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; + +export type ForkJoinTrackKey = "PIPE" | "FACILITY" | "TOTAL_AGG"; + +function normalizeBranchKey( + rawBranchSeCd?: string, + atrzSeComCd?: string, +): 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.includes("AGG") || upper.includes("정책") || upper.includes("TOTAL")) + return "TOTAL_AGG"; + } + if (atrzSeComCd === "ATRZ04") return "TOTAL_AGG"; + return null; +} + +export function createDefaultForkJoinRow( + branchSeCd: ForkJoinTrackKey, + deptNm: string, + seq: number, + seComCd: string, +): ElaprAplnRow { + return { + rowKey: `forkjoin-${branchSeCd}-${seq}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + elaprTmpltId: "TMPLT_SF_SP_FORKJOIN", + atrzSeqNo: seq, + atrzSeComCd: seComCd, + aprvrDeptNm: deptNm, + branchSeCd, + isNew: true, + isDirty: false, + dcarbYn: seComCd === "ATRZ99" ? "Y" : "N", + }; +} + +/** + * 1차 결재선(elaprCycl=1)의 진행상태 목록을 Fork-Join 3트랙(관로, 시설, 정책) 결재선 행으로 매핑합니다. + */ +export function mapDocPrgrsToForkJoinTrackRows( + list: ElaprDocPrgrsClientResponseDto[] | undefined, +): Record { + const tracks: Record = { + PIPE: [], + FACILITY: [], + TOTAL_AGG: [], + }; + + if (list && list.length > 0) { + list.forEach((item, index) => { + const branchKey = normalizeBranchKey(item.branchSeCd, item.atrzSeComCd); + if (branchKey) { + const seq = Number(item.atrzSeqNo ?? tracks[branchKey].length + 1); + const defaultDept = + branchKey === "PIPE" + ? "하수관로부" + : branchKey === "FACILITY" + ? "하수처리시설부" + : "정책총괄부"; + + const defaultSeComCd = + branchKey === "TOTAL_AGG" + ? seq === 1 + ? "ATRZ04" + : "ATRZ99" + : seq === 1 + ? "ATRZ02" + : "ATRZ03"; + + tracks[branchKey].push({ + rowKey: `forkjoin-docprgrs-${branchKey}-${seq}-${item.elaprPrgrsId ?? item.aprvrUuid ?? index}`, + elaprTmpltId: "TMPLT_SF_SP_FORKJOIN", + atrzSeqNo: seq, + atrzSeComCd: item.atrzSeComCd || defaultSeComCd, + atrzSeComNm: item.atrzSeComNm, + aprvrUuid: item.aprvrUuid, + aprvrNm: item.aprvrNm, + aprvrUserId: item.aprvrUserId, + aprvrDeptNm: item.aprvrDeptNm || item.ognzNm || defaultDept, + branchId: item.branchId, + branchSeCd: branchKey, + dcarbYn: item.dcarbYn ?? (item.atrzSeComCd === "ATRZ99" ? "Y" : "N"), + isNew: false, + isDirty: false, + }); + } + }); + + // 각 트랙별 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.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.FACILITY.length === 0) { + tracks.FACILITY = [ + createDefaultForkJoinRow("FACILITY", "하수처리시설부", 1, "ATRZ02"), + createDefaultForkJoinRow("FACILITY", "하수처리시설부", 2, "ATRZ03"), + ]; + } + if (tracks.TOTAL_AGG.length === 0) { + tracks.TOTAL_AGG = [ + createDefaultForkJoinRow("TOTAL_AGG", "정책총괄부", 1, "ATRZ04"), + createDefaultForkJoinRow("TOTAL_AGG", "정책총괄부", 2, "ATRZ99"), + ]; + } + + return tracks; +} 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 new file mode 100644 index 000000000..d6914e00a --- /dev/null +++ b/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-forkjoin-picker-dialog.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; + +import { useAlertStore } from "@/shared/lib"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@common/components"; + +import { useElaprDocPrgrsByDocNo } from "../api/queries"; +import { getVisibleRows } from "../lib/apln-validators"; +import { mapDocPrgrsToForkJoinTrackRows } from "../lib/doc-prgrs-to-forkjoin-rows"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; + +import { ElaprForkJoinPickerGrid } from "./elapr-forkjoin-picker-grid"; + +export interface ElaprDocPrgrsForkJoinPickerConfirmPayload { + aplnRows: ElaprAplnRow[]; +} + +export interface ElaprDocPrgrsForkJoinPickerDialogProps { + open: boolean; + elaprDocNo: string; + onClose: () => void; + onConfirm?: (payload: ElaprDocPrgrsForkJoinPickerConfirmPayload) => void; +} + +export function ElaprDocPrgrsForkJoinPickerDialog({ + open, + elaprDocNo, + onClose, + onConfirm, +}: ElaprDocPrgrsForkJoinPickerDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + const [aplnRows, setAplnRows] = useState([]); + const [isEditingLocked, setIsEditingLocked] = useState(false); + + // 1차 결재(elaprCycl=1)에서 지정된 6인 결재선 조회 + const docPrgrsQuery = useElaprDocPrgrsByDocNo(elaprDocNo, { + elaprCycl: 1, + enabled: open && Boolean(elaprDocNo), + }); + + // 1차 결재선 데이터를 3개 트랙(관로, 시설, 정책)으로 매핑 + const initialTrackRows = useMemo( + () => mapDocPrgrsToForkJoinTrackRows(docPrgrsQuery.data), + [docPrgrsQuery.data], + ); + + const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => { + setAplnRows(rows); + }, []); + + const handleEditingLockedChange = useCallback((locked: boolean) => { + setIsEditingLocked(locked); + }, []); + + function handleOpenChange(isOpen: boolean) { + if (!isOpen) { + setAplnRows([]); + setIsEditingLocked(false); + onClose(); + } + } + + function handleConfirm() { + if (isEditingLocked) { + alert("편집 중인 결재선이 있습니다. 저장을 완료해 주세요."); + return; + } + + const unassigned = aplnRows.find((r) => !r.aprvrUuid); + if (unassigned) { + alert("지정되지 않은 결재자가 있습니다. 모든 트랙의 결재자를 선택해 주세요."); + return; + } + + const pipeRows = aplnRows + .filter((r) => r.branchSeCd === "PIPE") + .sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); + const facRows = aplnRows + .filter((r) => r.branchSeCd === "FACILITY") + .sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); + const aggRows = aplnRows + .filter((r) => r.branchSeCd === "TOTAL_AGG") + .sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); + + // 1. 부서별 인원 수 (각각 최소 2명 이상, 최대 5명) + if (pipeRows.length < 2 || pipeRows.length > 5) { + alert("하수관로 검토 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요."); + return; + } + if (facRows.length < 2 || facRows.length > 5) { + alert("하수처리시설 검토 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요."); + return; + } + if (aggRows.length < 2 || aggRows.length > 5) { + alert("정책부 종합병합 결재선에 결재자를 최소 2명 이상(최대 5명) 지정해 주세요."); + return; + } + + // 2. 정책부 1순번 병합자(ATRZ04) 지정 검증 + if (aggRows[0]?.atrzSeComCd !== "ATRZ04") { + alert("[정책부] 첫 번째 결재구분은 '병합(ATRZ04)'이어야 합니다."); + return; + } + + // 3. 정책부 최종 결재구분(ATRZ99) 지정 검증 + const lastAggRow = aggRows[aggRows.length - 1]; + if (lastAggRow?.atrzSeComCd !== "ATRZ99") { + alert("[정책부] 최종 결재구분은 '최종승인(ATRZ99)'이어야 합니다."); + return; + } + + // 4. 동일 브랜치 내 중복 결재자 검증 + const checkDuplicateInBranch = (rows: typeof pipeRows, branchName: string) => { + const uuids = rows.map((r) => r.aprvrUuid).filter(Boolean); + const uniqueUuids = new Set(uuids); + if (uniqueUuids.size !== uuids.length) { + const dupRow = rows.find((r, i) => uuids.indexOf(r.aprvrUuid) !== i); + return `[${dupRow?.aprvrNm || "결재자"}] 결재자가 ${branchName} 결재선에 중복 지정되었습니다.`; + } + return null; + }; + + const pipeDup = checkDuplicateInBranch(pipeRows, "관로부"); + if (pipeDup) { + alert(pipeDup); + return; + } + const facDup = checkDuplicateInBranch(facRows, "시설부"); + if (facDup) { + alert(facDup); + return; + } + const aggDup = checkDuplicateInBranch(aggRows, "정책부"); + if (aggDup) { + alert(aggDup); + return; + } + + const visibleRows = getVisibleRows(aplnRows); + onConfirm?.({ aplnRows: visibleRows }); + + setAplnRows([]); + setIsEditingLocked(false); + onClose(); + } + + return ( + + + + 2차 결재선 선택 (복합사업 처리시설+하수관로 Fork-Join 3트랙) + + + {/* 1차 결재자 자동 매핑 안내 배너 */} +
+

+ ℹ️ 1차 기술검토 시 지정된 결재자(6인) 정보가 자동으로 매핑되었습니다. +

+

+ 동일 결재선으로 진행하거나 필요 시 결재자 변경/순서 조정한 후 하단의 [확인] 버튼을 눌러 + 2차 결재선을 확정해 주세요. +

+
+ + + + + + + +
+
+ ); +} 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 6f7428b4c..0b7679b90 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 @@ -83,33 +83,46 @@ function createDefaultRow( }; } -interface ElaprForkJoinPickerGridProps { +export interface ElaprForkJoinPickerGridProps { onRowsChange?: (rows: ElaprAplnRow[]) => void; onEditingLockedChange?: (locked: boolean) => void; + initialTrackRows?: Record | null; } export function ElaprForkJoinPickerGrid({ onRowsChange, onEditingLockedChange, + initialTrackRows, }: ElaprForkJoinPickerGridProps) { const alert = useAlertStore((state) => state.setMessage); const [activeTab, setActiveTab] = useState("PIPE"); // 3개 트랙별 결재선 상태 (10월 12일 개정: 각 2명씩 총 6인 체제) - const [trackRows, setTrackRows] = useState>(() => ({ - 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"), // 최종승인/전결 (환경청 회신) - ], - })); + const [trackRows, setTrackRows] = useState>(() => { + if (initialTrackRows) { + return initialTrackRows; + } + 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"), // 최종승인/전결 (환경청 회신) + ], + }; + }); + + useEffect(() => { + if (initialTrackRows) { + setTrackRows(initialTrackRows); + } + }, [initialTrackRows]); const [editingRowKey, setEditingRowKey] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); @@ -355,10 +368,9 @@ export function ElaprForkJoinPickerGrid({ - - - - + + + @@ -366,7 +378,6 @@ export function ElaprForkJoinPickerGrid({ 순서 결재구분 결재자 (성명/부서) - 전결 관리 diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts index d72503d1c..3dbfa15b0 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts @@ -224,7 +224,6 @@ const ROLE_USER01_FORK_JOIN_ATRZ_STATUS_ACTIONS: Record< ], ATRZ03: [ FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, - FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv, FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply, ], ATRZ04: [ @@ -285,7 +284,6 @@ const ROLE_USER01_FORK_JOIN_ATRZ_STATUS_ACTIONS: Record< ], ATRZ03: [ FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, - FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv, FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply, ], ATRZ04: [ 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 90b27befa..b5dbcb10f 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 @@ -20,6 +20,8 @@ import { ElaprApprovalDialog, type ElaprDmndSaveRequestDto, elaprDmndService, + type ElaprDocPrgrsForkJoinPickerConfirmPayload, + ElaprDocPrgrsForkJoinPickerDialog, type ElaprDocPrgrsPickerConfirmPayload, ElaprDocPrgrsPickerDialog, type ElaprTmpltPickerConfirmPayload, @@ -100,6 +102,7 @@ interface FloatingActionButtonsProps { type ActiveDialog = | "elaprTmplt" | "elaprDocPrgrs" + | "elaprDocPrgrsForkJoin" | "rjct" | "fileSplmnt" | "elaprApproval" @@ -196,9 +199,7 @@ function FloatingActionButtons({ const effectiveAtrzSeComCdList = useMemo(() => { if (isForkJoin && userPendingAtrzSeComCd) { - if (!atrzSeComCdList.includes(userPendingAtrzSeComCd)) { - return [...atrzSeComCdList, userPendingAtrzSeComCd]; - } + return [userPendingAtrzSeComCd]; } return atrzSeComCdList; }, [atrzSeComCdList, isForkJoin, userPendingAtrzSeComCd]); @@ -492,7 +493,9 @@ function FloatingActionButtons({ }); } - async function handleElaprDocPrgrsConfirm({ aplnRows }: ElaprDocPrgrsPickerConfirmPayload) { + async function handleElaprDocPrgrsConfirm({ + aplnRows, + }: ElaprDocPrgrsPickerConfirmPayload | ElaprDocPrgrsForkJoinPickerConfirmPayload) { const payload: ElaprDmndSaveRequestDto = { elaprDocNo: docNo, dmndTtl: data.detDto?.bizNm ?? "", @@ -770,7 +773,13 @@ function FloatingActionButtons({ !isRagIndexingInProgress(prgrsSttsComCd, data.ragIndexingInfo) && ( )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && ( - - )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && + (!isForkJoin || + (userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ04")) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && + (!isForkJoin || + (userPendingBranchSeCd !== "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ03") || + (userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ99")) && ( + + )} )} @@ -855,6 +864,13 @@ function FloatingActionButtons({ onConfirm={handleElaprDocPrgrsConfirm} /> + setActiveDialog(null)} + onConfirm={handleElaprDocPrgrsConfirm} + /> +