[as elapr] 재원업무 협의 2차 결재시 자동 검토자 지정 로직 추가

This commit is contained in:
2026-10-13 18:21:08 +09:00
parent 9a33663f1e
commit 6c71bccc27
7 changed files with 399 additions and 54 deletions
+9
View File
@@ -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 {
@@ -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<ForkJoinTrackKey, ElaprAplnRow[]> {
const tracks: Record<ForkJoinTrackKey, ElaprAplnRow[]> = {
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;
}
@@ -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<ElaprAplnRow[]>([]);
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[100rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>2차 결재선 선택 (복합사업 처리시설+하수관로 Fork-Join 3트랙)</DialogTitle>
</DialogHeader>
{/* 1차 결재자 자동 매핑 안내 배너 */}
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<p className="font-semibold">
ℹ️ 1차 기술검토 시 지정된 결재자(6인) 정보가 자동으로 매핑되었습니다.
</p>
<p className="mt-0.5 text-xs text-emerald-700">
동일 결재선으로 진행하거나 필요 시 결재자 변경/순서 조정한 후 하단의 [확인] 버튼을 눌러
2차 결재선을 확정해 주세요.
</p>
</div>
<ElaprForkJoinPickerGrid
initialTrackRows={initialTrackRows}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
<DialogFooter>
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
닫기
</Button>
<Button type="button" disabled={isEditingLocked} onClick={handleConfirm}>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -83,33 +83,46 @@ function createDefaultRow(
};
}
interface ElaprForkJoinPickerGridProps {
export interface ElaprForkJoinPickerGridProps {
onRowsChange?: (rows: ElaprAplnRow[]) => void;
onEditingLockedChange?: (locked: boolean) => void;
initialTrackRows?: Record<ForkJoinTrackKey, ElaprAplnRow[]> | null;
}
export function ElaprForkJoinPickerGrid({
onRowsChange,
onEditingLockedChange,
initialTrackRows,
}: ElaprForkJoinPickerGridProps) {
const alert = useAlertStore((state) => state.setMessage);
const [activeTab, setActiveTab] = useState<ForkJoinTrackKey>("PIPE");
// 3개 트랙별 결재선 상태 (10월 12일 개정: 각 2명씩 총 6인 체제)
const [trackRows, setTrackRows] = useState<Record<ForkJoinTrackKey, ElaprAplnRow[]>>(() => ({
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<Record<ForkJoinTrackKey, ElaprAplnRow[]>>(() => {
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<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -355,10 +368,9 @@ export function ElaprForkJoinPickerGrid({
<colgroup>
<col width="6%" />
<col width="10%" />
<col width="22%" />
<col width="34%" />
<col width="14%" />
<col width="14%" />
<col width="24%" />
<col width="44%" />
<col width="16%" />
</colgroup>
<TableHeader>
<TableRow>
@@ -366,7 +378,6 @@ export function ElaprForkJoinPickerGrid({
<TableHead>순서</TableHead>
<TableHead>결재구분</TableHead>
<TableHead>결재자 (성명/부서)</TableHead>
<TableHead>전결</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
@@ -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: [
@@ -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) && (
<Button
onClick={() =>
setActiveDialog(prgrsSttsComCd === "EV" ? "elaprDocPrgrs" : "elaprTmplt")
setActiveDialog(
prgrsSttsComCd === "EV"
? isForkJoin
? "elaprDocPrgrsForkJoin"
: "elaprDocPrgrs"
: "elaprTmplt",
)
}
disabled={isBusy}
>
@@ -806,33 +815,33 @@ function FloatingActionButtons({
보완 요청
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isBusy}>
{isForkJoin
? userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ04"
? "결재 (병합)"
: "결재 (검토)"
: "결재(승인)"}
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
<Button
color={
isForkJoin &&
userPendingBranchSeCd !== "TOTAL_AGG"
? "green"
: undefined
}
onClick={() => setActiveDialog("elaprApproval")}
disabled={isBusy}
>
{isForkJoin
? userPendingBranchSeCd === "TOTAL_AGG"
? "결재 (환경청에 회신)"
: "결재 (병합에 회신)"
: "결재(회신)"}
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) &&
(!isForkJoin ||
(userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ04")) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isBusy}>
{isForkJoin ? "결재 (병합)" : "결재(승인)"}
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) &&
(!isForkJoin ||
(userPendingBranchSeCd !== "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ03") ||
(userPendingBranchSeCd === "TOTAL_AGG" && userPendingAtrzSeComCd === "ATRZ99")) && (
<Button
color={
isForkJoin && userPendingBranchSeCd !== "TOTAL_AGG"
? "green"
: undefined
}
onClick={() => setActiveDialog("elaprApproval")}
disabled={isBusy}
>
{isForkJoin
? userPendingBranchSeCd === "TOTAL_AGG"
? "결재 (환경청에 회신)"
: "결재 (병합에 회신)"
: "결재(회신)"}
</Button>
)}
</>
)}
</div>
@@ -855,6 +864,13 @@ function FloatingActionButtons({
onConfirm={handleElaprDocPrgrsConfirm}
/>
<ElaprDocPrgrsForkJoinPickerDialog
open={activeDialog === "elaprDocPrgrsForkJoin"}
elaprDocNo={docNo}
onClose={() => setActiveDialog(null)}
onConfirm={handleElaprDocPrgrsConfirm}
/>
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={isDmndSplmntDialogOpen}
+1
View File
@@ -149,6 +149,7 @@ export interface ElaprDocPrgrsClientResponseDto {
atrzDt?: string | null;
jbpsSeNm?: string;
jbgdSeNm?: string;
aprvrDeptNm?: string;
ognzNm?: string;
dcarbYn?: string;
elaprCycl?: number;