[as elapr] 전자결재 forkjoin 통합 개발 화면

This commit is contained in:
2026-10-12 17:45:55 +09:00
parent c39d1f433a
commit 3f2938df1c
16 changed files with 1097 additions and 27 deletions
+10
View File
@@ -91,6 +91,14 @@ export type {
ElaprDmndService,
ElaprDocPrgrsResponseDto,
} from "./model/elapr-dmnd-interface";
export type {
AtrzSttsComCd,
BranchSeCd,
BranchSttsCd,
ElaprBranchDto,
ElaprForkJoinDataMap,
ElaprForkJoinStepDto,
} from "./model/elapr-forkjoin-types";
export type {
ElaprApproverChangeRequestDto,
ElaprApproverInsertRequestDto,
@@ -115,11 +123,13 @@ export type { ReviewerOption } from "./model/reviewer-option";
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid";
export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog";
export { ElaprDocPrgrsForkJoinStep } from "./ui/elapr-doc-prgrs-forkjoin-step";
export {
type ElaprDocPrgrsPickerConfirmPayload,
ElaprDocPrgrsPickerDialog,
} from "./ui/elapr-doc-prgrs-picker-dialog";
export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status";
export { ElaprForkJoinPickerGrid } from "./ui/elapr-forkjoin-picker-grid";
export {
type ElaprTmpltPickerConfirmPayload,
ElaprTmpltPickerDialog,
@@ -8,6 +8,9 @@ function toPrgrsSttsItem(
aprvrUuid: string | undefined,
atrzSeComCd: string | undefined,
atrzSeqNo: number | undefined,
branchId?: string,
branchSeCd?: string,
aprvrDeptNm?: string,
): ElaprPrgrsSttsSaveRequestDto {
if (!aprvrUuid?.trim()) {
throw new Error("결재자가 지정되지 않은 행이 있습니다.");
@@ -23,6 +26,9 @@ function toPrgrsSttsItem(
aprvrUuid,
atrzSeComCd,
atrzSeqNo,
branchId,
branchSeCd,
aprvrDeptNm,
};
}
@@ -38,6 +44,13 @@ export function buildElaprPrgrsSttsListFromAplnRows(
rows: ElaprAplnRow[],
): ElaprPrgrsSttsSaveRequestDto[] {
return getVisibleRows(rows).map((row) =>
toPrgrsSttsItem(row.aprvrUuid, row.atrzSeComCd, row.atrzSeqNo),
toPrgrsSttsItem(
row.aprvrUuid,
row.atrzSeComCd,
row.atrzSeqNo,
row.branchId,
row.branchSeCd,
row.aprvrDeptNm,
),
);
}
@@ -7,6 +7,11 @@ export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
aplnId?: string;
/** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */
aprvrNm?: string;
/** 소속 브랜치 ID 및 구분코드 */
branchId?: string;
branchSeCd?: "MAIN" | "PIPE" | "FACILITY" | "TOTAL_AGG";
aprvrDeptNm?: string;
dcarbYn?: string;
isNew?: boolean;
isDirty?: boolean;
/** 소프트 삭제 — 최종 저장 시 mode:D */
@@ -1,5 +1,6 @@
import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model";
import type { ElaprBranchDto } from "./elapr-forkjoin-types";
import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface";
/** 백엔드 ElaprDmndSaveRequestDto */
@@ -7,6 +8,9 @@ export interface ElaprDmndSaveRequestDto {
elaprDocNo: string;
dmndTtl: string;
dmndCn: string;
taskSeComCd?: string | null;
forkJoinSeCd?: string | null;
crntStageNo?: number | null;
elaprCycl?: number | null;
docPrgrsSttsComCd?: string | null;
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
@@ -19,8 +23,13 @@ export interface ElaprDmndResponseDto {
elaprDocNo: string;
dmndTtl: string;
dmndCn: string;
taskSeComCd?: string;
forkJoinSeCd?: string;
crntStageNo?: number;
elaprBranchList?: ElaprBranchDto[];
atrzSttsComCd: string;
atrzSttsComNm?: string;
elaprCycl?: number;
dmndDt?: string;
updtDt?: string;
}
@@ -0,0 +1,89 @@
/**
* 공공하수도 재원협의 복합사업(SF+SP) Fork-Join 병렬결재 모델 및 타입 정의 (10월 12일 개정: 6인 체제)
*/
/** 전자결재 마스터 진행상태 공통코드 (ATRZSTTS: tb_as_elapr_dmnd.atrz_stts_com_cd) */
export type AtrzSttsComCd =
| "ATRZ_STTS_001" // 대기중
| "ATRZ_STTS_002" // 결재진행중
| "ATRZ_STTS_003" // 결재완료
| "ATRZ_STTS_004" // 반려
| "ATRZ_STTS_005" // 회수
| "ATRZ_STTS_006"; // 취소
/** 결재 구분 공통코드 (ATRZSE: tb_as_elapr_prgrs_stts.atrz_se_com_cd) */
export type AtrzSeComCd =
| "ATRZ01" // 접수 (민원/신청 접수)
| "ATRZ02" // 검토 (실무 기술검토)
| "ATRZ03" // 승인 (부서 승인 / 병합에 회신)
| "ATRZ04" // 병합 (정책총괄 AI 병합의견 종합 심의 - 10-12 신설)
| "ATRZ99"; // 최종승인(회신) (환경청에 최종 회신)
/** 브랜치 구분코드 */
export type BranchSeCd = "MAIN" | "PIPE" | "FACILITY" | "TOTAL_AGG";
/** 브랜치 진행상태코드 */
export type BranchSttsCd =
| "WAITING"
| "IN_PROGRESS"
| "COMPLETED"
| "REJECTED"
| "CANCELLED";
/** 브랜치 단위 정보 DTO */
export interface ElaprBranchDto {
branchId: string;
elaprDmndId: string;
stageSeqNo: number; // 2: 병렬검토, 3: 합류취합
branchSeCd: BranchSeCd;
branchNm: string;
branchSttsCd: BranchSttsCd;
joinReqYn: "Y" | "N";
strtDt?: string;
endDt?: string;
}
/** 결재 진행 스텝 DTO (브랜치 확장) */
export interface ElaprForkJoinStepDto {
elaprPrgrsId: string;
elaprDmndId?: string;
branchId?: string;
branchSeCd?: BranchSeCd;
aprvrUuid: string;
aprvrNm?: string;
aprvrDeptNm?: string;
ognzNm?: string;
jbpsSeNm?: string;
atrzSeComCd: AtrzSeComCd | string; // ATRZ01: 접수, ATRZ02: 검토, ATRZ03: 승인, ATRZ04: 병합, ATRZ99: 최종승인(회신)
atrzSeComNm?: string;
atrzSeqNo: number;
atrzPrgrsSttsComCd: string; // ATRZ_PRGRS_001: 대기, ATRZ_PRGRS_002: 요청, ATRZ_PRGRS_003: 완료, ATRZ_PRGRS_004: 반려, ATRZ_PRGRS_999: 결재안함
atrzPrgrsSttsComNm?: string;
rjctRsn?: string;
atrzDt?: string;
dcarbYn?: string;
elaprCycl?: number;
}
/** 3개 트랙으로 분할된 결재선 데이터 맵 (10-12 개정: 총 6인 체제) */
export interface ElaprForkJoinDataMap {
pipeTrack: ElaprForkJoinStepDto[]; // 관로부 (2명: 검토(02) ➔ 승인(03))
facilityTrack: ElaprForkJoinStepDto[]; // 시설부 (2명: 검토(02) ➔ 승인(03))
aggTrack: ElaprForkJoinStepDto[]; // 정책부 종합병합 (2명: 병합(04) ➔ 최종승인(99))
}
/** 결재 승인 요청 DTO */
export interface ElaprApproveRequestDto {
elaprDmndId: string;
branchId: string;
elaprPrgrsId: string;
atrzOpnn?: string;
}
/** 결재 단계별 반려 요청 DTO (10-12 개정) */
export interface ElaprRejectRequestDto {
elaprDmndId: string;
branchId: string;
elaprPrgrsId: string;
rjctRsn: string; // 반려 사유 필수
}
@@ -14,6 +14,9 @@ export type ElaprPrgrsSttsSaveMode =
export interface ElaprPrgrsSttsResponseDto {
elaprPrgrsId: string;
elaprDmndId?: string;
branchId?: string;
branchSeCd?: string;
aprvrDeptNm?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeComNm?: string;
@@ -30,6 +33,9 @@ export interface ElaprPrgrsSttsSaveRequestDto {
mode?: ElaprPrgrsSttsSaveMode;
elaprPrgrsId?: string;
elaprDmndId?: string;
branchId?: string;
branchSeCd?: string;
aprvrDeptNm?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeqNo?: number;
@@ -49,6 +49,7 @@ interface ElaprApprovalDialogProps {
elaprDocNo: string;
currentUserUuid: string;
elaprCycl?: number;
taskSeComCd?: string | null;
onSuccess: () => void;
}
@@ -60,6 +61,7 @@ function ElaprApprovalDialog({
elaprDocNo,
currentUserUuid,
elaprCycl,
taskSeComCd,
onSuccess,
}: ElaprApprovalDialogProps) {
const queryClient = useQueryClient();
@@ -104,7 +106,29 @@ function ElaprApprovalDialog({
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
const canReject =
currentUserPrgrs?.atrzSeComCd === "ATRZ03" || currentUserPrgrs?.atrzSeComCd === "ATRZ99";
currentUserPrgrs?.atrzSeComCd === "ATRZ03" ||
currentUserPrgrs?.atrzSeComCd === "ATRZ04" ||
currentUserPrgrs?.atrzSeComCd === "ATRZ99" ||
(currentUserPrgrs?.atrzSeqNo ?? 1) > 1;
const branchSeCd = (currentUserPrgrs as { branchSeCd?: string } | undefined)?.branchSeCd;
const isForkJoin =
taskSeComCd === "SF_SP" ||
branchSeCd === "PIPE" ||
branchSeCd === "FACILITY" ||
branchSeCd === "TOTAL_AGG";
const isLastInBranch =
currentUserPrgrs?.atrzSeComCd === "ATRZ99" || currentUserPrgrs?.atrzSeComCd === "ATRZ03";
const approveButtonLabel = useMemo(() => {
if (!isForkJoin) return "결재";
if (branchSeCd === "TOTAL_AGG" && isLastInBranch) return "결재 (환경청에 최종회신)";
if (branchSeCd === "TOTAL_AGG") return "결재 (병합)";
if ((branchSeCd === "PIPE" || branchSeCd === "FACILITY") && isLastInBranch)
return "결재 (병합에 회신)";
return "결재 (검토)";
}, [isForkJoin, branchSeCd, isLastInBranch]);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
@@ -239,7 +263,11 @@ function ElaprApprovalDialog({
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
rjctRsn: rjctRsn.trim(),
});
alert("반려 처리가 완료되었습니다.");
alert(
isForkJoin
? "결재가 반려 처리되었습니다.\n직전 결재자가 '결재 대기중'으로 변경되었습니다. 상대 부서 결재는 정상 진행됩니다."
: "반려 처리가 완료되었습니다.",
);
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
});
@@ -272,6 +300,42 @@ function ElaprApprovalDialog({
</DialogHeader>
<div className="space-y-6">
{isForkJoin && (
<div className="rounded-lg border border-blue-200 bg-blue-50/70 p-4 text-sm text-blue-900">
<div className="mb-1 flex items-center gap-2 font-bold">
<span className="rounded-sm bg-blue-200 px-2 py-0.5 text-xs text-blue-950">
{branchSeCd === "PIPE"
? "🌊 하수관로 전문기술검토 (Stage 2)"
: branchSeCd === "FACILITY"
? "🏭 하수처리시설 전문기술검토 (Stage 2)"
: branchSeCd === "TOTAL_AGG"
? isLastInBranch
? "⚖️ 정책총괄 최종승인 및 회신 (Stage 3)"
: "⚖️ 정책총괄 AI 종합병합 (Stage 3)"
: "복합사업 결재"}
</span>
<span>
{branchSeCd === "TOTAL_AGG"
? isLastInBranch
? "환경청 대외 최종 회신 단계"
: "AI 의견 종합 및 병합 심의 단계"
: isLastInBranch
? "병합부서(정책총괄) 내부 회신(이관) 단계"
: "실무 기술검토 단계"}
</span>
</div>
<p className="text-xs text-blue-800">
{branchSeCd === "TOTAL_AGG"
? isLastInBranch
? "ℹ️ [중요] 본 승인은 환경청에 최종 회신하는 결재입니다. 승인 즉시 공문이 대외 발송되며 원천문서 상태가 TP(기술검토회신) 또는 TR(최종실무협의완료)로 전이됩니다."
: "ℹ️ [안내] 본 승인은 'AI의견 종합 및 병합 심의' 단계입니다. 양 부서 검토의견을 종합 반영하여 정책실장(최종 전결권자)에게 이관됩니다."
: isLastInBranch
? "ℹ️ [안내] 본 승인은 '병합에 회신'하는 내부 이관 단계입니다. 환경청으로 직접 공문이 나가지 않으며, 상대방 부서 검토 완료 시 종합병합 단계로 자동 합류(Join)됩니다."
: "ℹ️ [안내] 본 단계는 실무 기술검토 단계입니다. 검토 완료 후 부서 승인자에게 이관됩니다."}
</p>
</div>
)}
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h4 className="text-xl font-semibold">결재 진행 단계</h4>
@@ -372,6 +436,17 @@ function ElaprApprovalDialog({
{canApprove && (
<div>
{isForkJoin && canReject && (
<div className="mb-2 space-y-1 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900">
<div className="font-bold">⚠️ [단계별 반려 안내 (10월 12일 개정)]</div>
<p>현재 복합사업 병렬 검토가 진행 중입니다. 본 단계에서 반려 처리 시:</p>
<ul className="list-disc pl-4 space-y-0.5">
<li>귀하(현재 결재자)는 반려(ATRZ_PRGRS_004) 처리됩니다.</li>
<li>이전(직전) 결재자만 &apos;결재 대기중(ATRZ_PRGRS_002)&apos;으로 변경되어 보완 및 재상신 기회가 부여됩니다.</li>
<li>동시에 진행 중인 상대방 부서 결재는 중단되지 않고 독립 정상 진행됩니다.</li>
</ul>
</div>
)}
<h4 className="mb-3 text-xl font-semibold">반려 의견</h4>
<Textarea
value={rjctRsn}
@@ -392,7 +467,7 @@ function ElaprApprovalDialog({
{canApprove && (
<div className="flex items-center gap-2">
<Button onClick={handleApprove} disabled={isBusy}>
결재
{approveButtonLabel}
</Button>
{canReject && (
<Button color="red" onClick={handleReject} disabled={isBusy}>
@@ -0,0 +1,235 @@
"use client";
import { useMemo } from "react";
import { ArrowDown, ArrowRight, CheckCircle2, ChevronRight, GitFork, GitMerge } from "lucide-react";
import { dayjs } from "@/shared/lib";
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@common/components";
import { getApproverStatus } from "../lib/approval-utils";
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
interface ElaprDocPrgrsForkJoinStepProps {
steps: ElaprDocPrgrsClientResponseDto[];
}
function ApproverCard({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
const status = getApproverStatus(item.atrzPrgrsSttsComCd, item.atrzSeComCd);
const isRejected = item.atrzPrgrsSttsComCd === REJECT_STATUS_CD;
const isCurrent = item.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002";
const rjctRsn = item.rjctRsn?.trim();
const metaParts = [item.ognzNm, item.jbpsSeNm].filter(Boolean);
const statusBadge = (
<span
className={cn(
"inline-flex items-center rounded-sm px-3 py-1 text-sm font-semibold",
status.badgeClass,
isCurrent && "animate-pulse ring-2 ring-blue-400",
isRejected && rjctRsn && "cursor-help underline decoration-dotted underline-offset-4",
)}
>
{status.label}
</span>
);
return (
<div className="flex min-w-[13rem] flex-1 flex-col items-center gap-2 rounded-lg border border-gray-200 bg-white p-4 text-center shadow-xs">
{isRejected && rjctRsn ? (
<Tooltip>
<TooltipTrigger asChild>{statusBadge}</TooltipTrigger>
<TooltipContent sideOffset={4} className="max-w-md whitespace-pre-wrap">
{rjctRsn}
</TooltipContent>
</Tooltip>
) : (
statusBadge
)}
<div className="flex flex-col items-center gap-1">
<p className="text-base font-semibold text-gray-900">
<span className="text-muted-foreground">[{item.atrzSeComNm || "-"}]</span>{" "}
{item.aprvrNm || item.aprvrUuid || "-"}
</p>
{metaParts.length > 0 && (
<span className="text-xs text-gray-500">{metaParts.join(" · ")}</span>
)}
{item.atrzDt && (
<span className="text-xs text-gray-400">{dayjs(item.atrzDt).format("MM-DD HH:mm")}</span>
)}
</div>
</div>
);
}
export function ElaprDocPrgrsForkJoinStep({ steps }: ElaprDocPrgrsForkJoinStepProps) {
// 브랜치별 분할 (branchSeCd 우선, 없으면 seqNo fallback)
const { pipeSteps, facSteps, aggSteps } = useMemo(() => {
const pipe: ElaprDocPrgrsClientResponseDto[] = [];
const fac: ElaprDocPrgrsClientResponseDto[] = [];
const agg: ElaprDocPrgrsClientResponseDto[] = [];
steps.forEach((step) => {
const seCd = (step as { branchSeCd?: string }).branchSeCd;
if (seCd === "PIPE") {
pipe.push(step);
} else if (seCd === "FACILITY" || seCd === "FAC") {
fac.push(step);
} else if (seCd === "TOTAL_AGG" || seCd === "POL_JOIN" || seCd === "AGG") {
agg.push(step);
} else {
// 순번 fallback (6인 체제: 2/2/2)
const seq = Number(step.atrzSeqNo ?? 1);
if (seq <= 2) pipe.push(step);
else if (seq <= 4) fac.push(step);
else agg.push(step);
}
});
return {
pipeSteps: pipe.sort((a, b) => Number(a.atrzSeqNo) - Number(b.atrzSeqNo)),
facSteps: fac.sort((a, b) => Number(a.atrzSeqNo) - Number(b.atrzSeqNo)),
aggSteps: agg.sort((a, b) => Number(a.atrzSeqNo) - Number(b.atrzSeqNo)),
};
}, [steps]);
// 완료 상태 판정
const isPipeCompleted =
pipeSteps.length > 0 &&
pipeSteps.every((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003");
const isFacCompleted =
facSteps.length > 0 &&
facSteps.every((s) => s.atrzPrgrsSttsComCd === "ATRZ_PRGRS_003");
const isJoinReady = isPipeCompleted && isFacCompleted;
return (
<div className="flex flex-col gap-6 p-2">
<div className="flex flex-col gap-6 lg:flex-row lg:items-stretch">
{/* Stage 2: 상/하 2열 병렬 트랙 */}
<div className="flex flex-1 flex-col gap-4 rounded-xl border border-blue-100 bg-blue-50/40 p-4">
<div className="flex items-center gap-2 border-b border-blue-200/60 pb-2">
<GitFork className="size-5 text-blue-600" />
<h4 className="text-base font-bold text-blue-950">
Stage 2: 부서별 병렬 전문검토 (각 2인)
</h4>
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-800">
동시 진행
</span>
</div>
{/* 1) 관로부 트랙 */}
<div className="rounded-lg border border-cyan-200 bg-cyan-50/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-bold text-cyan-900">🌊 하수관로 전문검토</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isPipeCompleted
? "bg-emerald-100 text-emerald-800"
: "bg-cyan-100 text-cyan-800"
}`}
>
{isPipeCompleted ? "병합에 회신 완료 (2/2)" : "진행중"}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{pipeSteps.map((item, idx) => (
<div key={item.elaprPrgrsId ?? idx} className="flex items-center gap-2">
<ApproverCard item={item} />
{idx < pipeSteps.length - 1 && (
<ChevronRight className="size-5 text-cyan-400" />
)}
</div>
))}
</div>
</div>
{/* 2) 시설부 트랙 */}
<div className="rounded-lg border border-indigo-200 bg-indigo-50/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-bold text-indigo-900">🏭 하수처리시설 전문검토</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isFacCompleted
? "bg-emerald-100 text-emerald-800"
: "bg-indigo-100 text-indigo-800"
}`}
>
{isFacCompleted ? "병합에 회신 완료 (2/2)" : "진행중"}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{facSteps.map((item, idx) => (
<div key={item.elaprPrgrsId ?? idx} className="flex items-center gap-2">
<ApproverCard item={item} />
{idx < facSteps.length - 1 && (
<ChevronRight className="size-5 text-indigo-400" />
)}
</div>
))}
</div>
</div>
</div>
{/* JOIN 합류 다이어그램 연결 */}
<div className="flex items-center justify-center px-2">
<div className="flex flex-col items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50/80 p-4 text-center shadow-xs">
<GitMerge className="size-6 text-emerald-600" />
<span className="text-xs font-bold text-emerald-900">JOIN 합류</span>
<div className="flex flex-col gap-1 text-[11px] text-emerald-800">
<span className="flex items-center gap-1">
{isPipeCompleted ? "🟢" : "⏳"} 관로 {isPipeCompleted ? "완료" : "대기"}
</span>
<span className="flex items-center gap-1">
{isFacCompleted ? "🟢" : "⏳"} 시설 {isFacCompleted ? "완료" : "대기"}
</span>
</div>
<div
className={`mt-1 rounded-sm px-2 py-1 text-xs font-bold ${
isJoinReady
? "bg-emerald-200 text-emerald-900"
: "bg-gray-200 text-gray-700"
}`}
>
{isJoinReady ? "Stage 3 활성화됨" : "병렬 대기중"}
</div>
<ArrowRight className="hidden size-5 text-emerald-600 lg:block" />
<ArrowDown className="size-5 text-emerald-600 lg:hidden" />
</div>
</div>
{/* Stage 3: 정책총괄부 종합취합 및 환경청 최종회신 트랙 */}
<div className="flex flex-1 flex-col gap-4 rounded-xl border border-purple-100 bg-purple-50/40 p-4">
<div className="flex items-center gap-2 border-b border-purple-200/60 pb-2">
<CheckCircle2 className="size-5 text-purple-600" />
<h4 className="text-base font-bold text-purple-950">
Stage 3: 정책총괄 종합병합 및 환경청 회신 (2인)
</h4>
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
isJoinReady
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-600"
}`}
>
{isJoinReady ? "진행 가능" : "대기"}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-purple-200 bg-purple-50/50 p-3">
{aggSteps.map((item, idx) => (
<div key={item.elaprPrgrsId ?? idx} className="flex items-center gap-2">
<ApproverCard item={item} />
{idx < aggSteps.length - 1 && (
<ChevronRight className="size-5 text-purple-400" />
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -13,10 +13,13 @@ import { cn, Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/
import { useElaprDocPrgrsByDocNo } from "../api/queries";
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
import { ElaprDocPrgrsForkJoinStep } from "./elapr-doc-prgrs-forkjoin-step";
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
interface ElaprDocPrgrsStatusProps {
elaprDocNo: string | null | undefined;
taskSeComCd?: string | null;
}
function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
@@ -88,21 +91,33 @@ function ElaprDocPrgrsStepList({ steps }: { steps: ElaprDocPrgrsClientResponseDt
);
}
function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
function ElaprDocPrgrsStatus({ elaprDocNo, taskSeComCd }: ElaprDocPrgrsStatusProps) {
const userInfo = useUserStore((state) => state.userInfo);
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
const [activeCycl, setActiveCycl] = useState<"1" | "2">("1");
const { cycl1Steps, cycl2Steps, hasCycl2 } = useMemo(() => {
const { cycl1Steps, cycl2Steps, hasCycl2, isForkJoin1, isForkJoin2 } = useMemo(() => {
const rows = data ?? [];
const cycl1 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl ?? 1) !== 2));
const cycl2 = sortByAtrzSeqNo(rows.filter((item) => Number(item.elaprCycl) === 2));
const checkForkJoin = (list: typeof rows) =>
taskSeComCd === "SF_SP" ||
list.some(
(s) =>
(s as { branchSeCd?: string }).branchSeCd === "PIPE" ||
(s as { branchSeCd?: string }).branchSeCd === "FACILITY",
) ||
list.length >= 6;
return {
cycl1Steps: cycl1,
cycl2Steps: cycl2,
hasCycl2: cycl2.length > 0,
isForkJoin1: checkForkJoin(cycl1),
isForkJoin2: checkForkJoin(cycl2),
};
}, [data]);
}, [data, taskSeComCd]);
useEffect(() => {
setActiveCycl(hasCycl2 ? "2" : "1");
@@ -138,23 +153,33 @@ function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
>
<TabsList>
<TabsTrigger value="1" className="!px-6 !py-3 text-xl">
재원협의
1차 실무협의 (기술검토)
</TabsTrigger>
<TabsTrigger value="2" className="!px-6 !py-3 text-xl">
실무협의서
2차 조치결과 검토 (종결)
</TabsTrigger>
</TabsList>
<TabsContent value="1" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl1Steps} />
{isForkJoin1 ? (
<ElaprDocPrgrsForkJoinStep steps={cycl1Steps} />
) : (
<ElaprDocPrgrsStepList steps={cycl1Steps} />
)}
</div>
</TabsContent>
<TabsContent value="2" className="mt-2 border-none p-0 shadow-none">
<div className="-mx-2">
<ElaprDocPrgrsStepList steps={cycl2Steps} />
{isForkJoin2 ? (
<ElaprDocPrgrsForkJoinStep steps={cycl2Steps} />
) : (
<ElaprDocPrgrsStepList steps={cycl2Steps} />
)}
</div>
</TabsContent>
</Tabs>
) : isForkJoin1 ? (
<ElaprDocPrgrsForkJoinStep steps={cycl1Steps} />
) : (
<ElaprDocPrgrsStepList steps={cycl1Steps} />
)}
@@ -0,0 +1,412 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAlertStore } from "@/shared/lib";
import {
Button,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@common/components";
import { useDndSensors } from "../lib/use-dnd-sensors";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
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";
interface TrackConfig {
key: ForkJoinTrackKey;
label: string;
defaultDeptNm: string;
guideText: string;
minCount: number;
maxCount: number;
}
const TRACK_CONFIGS: TrackConfig[] = [
{
key: "PIPE",
label: "하수관로 검토",
defaultDeptNm: "하수관로부",
guideText: "ℹ️ 하수관로 부서 검토·승인 완료 시 종합병합부서로 내부 회신(이관)됩니다.",
minCount: 2,
maxCount: 5,
},
{
key: "FACILITY",
label: "하수처리시설 검토",
defaultDeptNm: "하수처리시설부",
guideText: "ℹ️ 하수처리시설 부서 검토·승인 완료 시 종합병합부서로 내부 회신(이관)됩니다.",
minCount: 2,
maxCount: 5,
},
{
key: "TOTAL_AGG",
label: "정책부 종합병합",
defaultDeptNm: "정책총괄부",
guideText: "ℹ️ 두 부서(관로+시설) Join 완료 후 정책취합 및 환경청 최종회신 단계로 진행됩니다.",
minCount: 2,
maxCount: 5,
},
];
function createDefaultRow(
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",
};
}
interface ElaprForkJoinPickerGridProps {
onRowsChange?: (rows: ElaprAplnRow[]) => void;
onEditingLockedChange?: (locked: boolean) => void;
}
export function ElaprForkJoinPickerGrid({
onRowsChange,
onEditingLockedChange,
}: 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 [editingRowKey, setEditingRowKey] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
const sensors = useDndSensors();
// 통합 전체 결재선 목록
const allRows = useMemo(() => {
return [...trackRows.PIPE, ...trackRows.FACILITY, ...trackRows.TOTAL_AGG];
}, [trackRows]);
useEffect(() => {
onRowsChange?.(allRows);
}, [allRows, onRowsChange]);
useEffect(() => {
onEditingLockedChange?.(editingRowKey !== null);
}, [editingRowKey, onEditingLockedChange]);
const currentTrackConfig = useMemo(() => {
return TRACK_CONFIGS.find((c) => c.key === activeTab) ?? TRACK_CONFIGS[0];
}, [activeTab]);
const currentRows = trackRows[activeTab] ?? [];
const pickingRow = useMemo(() => {
if (!pickingRowKey) return undefined;
return allRows.find((r) => r.rowKey === pickingRowKey);
}, [pickingRowKey, allRows]);
// 행 추가 (부서별 최대 5명 제한)
const handleAddRow = useCallback(() => {
if (editingRowKey) {
alert("편집 중인 결재선이 있습니다. 저장 또는 취소 후 추가해 주세요.");
return;
}
if (currentRows.length >= currentTrackConfig.maxCount) {
alert(`[${currentTrackConfig.label}] 결재선은 최대 ${currentTrackConfig.maxCount}명까지 지정 가능합니다.`);
return;
}
const newSeq = currentRows.length + 1;
let defaultSeComCd = "ATRZ02";
if (activeTab === "TOTAL_AGG") {
defaultSeComCd = newSeq === 1 ? "ATRZ04" : "ATRZ99";
} else {
defaultSeComCd = newSeq === 1 ? "ATRZ02" : "ATRZ03";
}
const newRow = createDefaultRow(
activeTab,
currentTrackConfig.defaultDeptNm,
newSeq,
defaultSeComCd,
);
setTrackRows((prev) => ({
...prev,
[activeTab]: [...prev[activeTab], newRow],
}));
setEditingRowKey(newRow.rowKey);
}, [activeTab, currentRows.length, currentTrackConfig, editingRowKey, alert]);
// 편집 시작
const handleStartEdit = useCallback((rowKey: string) => {
const target = currentRows.find((r) => r.rowKey === rowKey);
if (!target) return;
target.snapshot = { ...target };
setEditingRowKey(rowKey);
}, [currentRows]);
// 편집 취소
const handleCancelEdit = useCallback(() => {
if (!editingRowKey) return;
setTrackRows((prev) => {
const list = prev[activeTab];
const target = list.find((r) => r.rowKey === editingRowKey);
if (target?.isNew && !target.aprvrUuid) {
return {
...prev,
[activeTab]: list.filter((r) => r.rowKey !== editingRowKey),
};
}
if (target?.snapshot) {
return {
...prev,
[activeTab]: list.map((r) =>
r.rowKey === editingRowKey ? { ...r.snapshot!, snapshot: undefined } : r,
),
};
}
return prev;
});
setEditingRowKey(null);
}, [activeTab, editingRowKey]);
// 행 필드 수정
const handleUpdateRow = useCallback(
(rowKey: string, patch: Partial<ElaprAplnRow>) => {
setTrackRows((prev) => ({
...prev,
[activeTab]: prev[activeTab].map((r) =>
r.rowKey === rowKey ? { ...r, ...patch, isDirty: true } : r,
),
}));
},
[activeTab],
);
// 저장 (커밋)
const handleCommitEdit = useCallback((rowKey: string) => {
setTrackRows((prev) => ({
...prev,
[activeTab]: prev[activeTab].map((r) =>
r.rowKey === rowKey ? { ...r, isDirty: false, isNew: false, snapshot: undefined } : r,
),
}));
setEditingRowKey(null);
}, [activeTab]);
// 행 삭제
const handleDeleteRow = useCallback(
(rowKey: string) => {
setTrackRows((prev) => {
const nextList = prev[activeTab]
.filter((r) => r.rowKey !== rowKey)
.map((r, idx) => ({ ...r, atrzSeqNo: idx + 1 }));
return {
...prev,
[activeTab]: nextList,
};
});
if (editingRowKey === rowKey) {
setEditingRowKey(null);
}
},
[activeTab, editingRowKey],
);
// 드래그 순서 재정렬
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setTrackRows((prev) => {
const items = [...prev[activeTab]];
const oldIndex = items.findIndex((r) => r.rowKey === active.id);
const newIndex = items.findIndex((r) => r.rowKey === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
const [moved] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, moved);
const reordered = items.map((r, idx) => ({
...r,
atrzSeqNo: idx + 1,
isDirty: true,
}));
return {
...prev,
[activeTab]: reordered,
};
});
},
[activeTab],
);
// 결재자 선택 팝업 오픈
const handlePickApprover = useCallback((rowKey: string) => {
setPickingRowKey(rowKey);
setPickerOpen(true);
}, []);
// 결재자 선택 완료
const handleSelectReviewer = useCallback(
(reviewer: ReviewerOption) => {
if (!pickingRowKey) return;
handleUpdateRow(pickingRowKey, {
aprvrUuid: reviewer.uuid,
aprvrNm: reviewer.name,
aprvrDeptNm: reviewer.deptNm ?? currentTrackConfig.defaultDeptNm,
});
setPickingRowKey(null);
setPickerOpen(false);
},
[pickingRowKey, handleUpdateRow, currentTrackConfig.defaultDeptNm],
);
return (
<div className="flex flex-1 flex-col gap-4">
{/* 3트랙 탭 헤더 */}
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as ForkJoinTrackKey)}>
<TabsList className="grid w-full grid-cols-3">
{TRACK_CONFIGS.map((track) => {
const count = trackRows[track.key].filter((r) => Boolean(r.aprvrUuid)).length;
const total = trackRows[track.key].length;
const isFull = count >= track.minCount;
return (
<TabsTrigger
key={track.key}
value={track.key}
className="flex items-center gap-2 py-3 text-lg font-semibold"
>
<span>{track.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
isFull
? "bg-emerald-100 text-emerald-700"
: "bg-amber-100 text-amber-700"
}`}
>
{count}/{total}
</span>
</TabsTrigger>
);
})}
</TabsList>
{TRACK_CONFIGS.map((track) => (
<TabsContent key={track.key} value={track.key} className="mt-3">
<div className="mb-3 flex items-center justify-between rounded-lg bg-blue-50 p-3 text-blue-900">
<span className="text-sm font-medium">{track.guideText}</span>
<Button
type="button"
size="sm"
color="white"
onClick={handleAddRow}
disabled={Boolean(editingRowKey) || trackRows[track.key].length >= track.maxCount}
>
+ 결재자 추가
</Button>
</div>
{/* 해당 탭 결재선 테이블 */}
<div className="rounded-lg border border-gray-200">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table className="w-full">
<colgroup>
<col width="6%" />
<col width="10%" />
<col width="22%" />
<col width="34%" />
<col width="14%" />
<col width="14%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>순서</TableHead>
<TableHead>결재구분</TableHead>
<TableHead>결재자 (성명/부서)</TableHead>
<TableHead>전결</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<SortableContext
items={trackRows[track.key].map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
{trackRows[track.key].map((row, index) => (
<SortableAplnPickerRow
key={row.rowKey}
row={row}
displaySeqNo={index + 1}
dragDisabled={Boolean(editingRowKey)}
isEditing={editingRowKey === row.rowKey}
isLocked={Boolean(editingRowKey && editingRowKey !== row.rowKey)}
onStartEdit={() => handleStartEdit(row.rowKey)}
onSave={() => handleCommitEdit(row.rowKey)}
onCancel={handleCancelEdit}
onDelete={() => handleDeleteRow(row.rowKey)}
onChange={(patch) => handleUpdateRow(row.rowKey, patch)}
onPickAprvr={() => handlePickApprover(row.rowKey)}
/>
))}
</SortableContext>
</TableBody>
</Table>
</DndContext>
</div>
</TabsContent>
))}
</Tabs>
{/* 결재자 검색 팝업 */}
<AprvrPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
onSelect={handleSelectReviewer}
/>
</div>
);
}
@@ -24,6 +24,7 @@ import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
import { ElaprForkJoinPickerGrid } from "./elapr-forkjoin-picker-grid";
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
const PAGE_SIZE = 100;
@@ -37,6 +38,7 @@ interface ElaprTmpltPickerDialogProps {
open: boolean;
onClose: () => void;
docSeComCd: string;
taskSeComCd?: string | null;
onSelect?: (tmplt: ElaprTmplt) => void;
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
}
@@ -45,6 +47,7 @@ function ElaprTmpltPickerDialog({
open,
onClose,
docSeComCd,
taskSeComCd,
onSelect,
onConfirm,
}: ElaprTmpltPickerDialogProps) {
@@ -53,6 +56,8 @@ function ElaprTmpltPickerDialog({
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
const [isEditingLocked, setIsEditingLocked] = useState(false);
const isForkJoinMode = docSeComCd === "FNRS" && taskSeComCd === "SF_SP";
const tmpltQuery = useElaprTmpltPage({
page: 0,
size: PAGE_SIZE,
@@ -70,7 +75,9 @@ function ElaprTmpltPickerDialog({
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
setSelectedTmplt(tmplt);
setAplnRows([]);
if (!isForkJoinMode) {
setAplnRows([]);
}
setIsEditingLocked(false);
}
@@ -84,11 +91,110 @@ function ElaprTmpltPickerDialog({
}
function handleConfirm() {
if (!selectedTmplt) {
if (!selectedTmplt && !isForkJoinMode) {
alert("결재서식을 선택해 주세요.");
return;
}
if (isForkJoinMode) {
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 defaultTmplt: ElaprTmplt = selectedTmplt ?? {
elaprTmpltId: "TMPLT_SF_SP_FORKJOIN",
elaprTmpltNm: "[복합] 시설+관로 병렬 결재선 (6인)",
docSeComCd: "FNRS",
};
const payload: ElaprTmpltPickerConfirmPayload = {
tmplt: defaultTmplt,
aplnRows,
};
if (onConfirm) {
onConfirm(payload);
} else {
onSelect?.(defaultTmplt);
}
setSelectedTmplt(null);
setAplnRows([]);
setIsEditingLocked(false);
onClose();
return;
}
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
if (rowsError) {
alert(rowsError);
@@ -116,14 +222,14 @@ function ElaprTmpltPickerDialog({
const visibleRows = getVisibleRows(aplnRows);
const payload: ElaprTmpltPickerConfirmPayload = {
tmplt: selectedTmplt,
tmplt: selectedTmplt!,
aplnRows: visibleRows,
};
if (onConfirm) {
onConfirm(payload);
} else {
onSelect?.(selectedTmplt);
onSelect?.(selectedTmplt!);
}
setSelectedTmplt(null);
@@ -134,9 +240,16 @@ function ElaprTmpltPickerDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
<DialogContent
className={isForkJoinMode ? "max-w-[140rem]" : "max-w-[120rem]"}
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>결재선 선택</DialogTitle>
<DialogTitle>
{isForkJoinMode
? "결재선 선택 (복합사업 처리시설+하수관로 Fork-Join 3트랙)"
: "결재선 선택"}
</DialogTitle>
</DialogHeader>
<div className="flex gap-6">
@@ -145,11 +258,19 @@ function ElaprTmpltPickerDialog({
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onSelectTmplt={handleSelectTmplt}
/>
<ElaprAplnPickerGrid
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
{isForkJoinMode ? (
<ElaprForkJoinPickerGrid
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
) : (
<ElaprAplnPickerGrid
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
)}
</div>
<DialogFooter>
@@ -158,7 +279,7 @@ function ElaprTmpltPickerDialog({
</Button>
<Button
type="button"
disabled={!selectedTmplt || isEditingLocked}
disabled={(!selectedTmplt && !isForkJoinMode) || isEditingLocked}
onClick={handleConfirm}
>
확인
@@ -98,3 +98,27 @@ export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
/**
* 재원업무협의 저장 시 코드가 "SF,SP", "SP,SF" 등의 복수 코드로 분리 저장된 경우,
* 전자결재 복합병렬결재(Fork-Join) 화면 및 프로세스 규격에 맞춰 "SF_SP"로 정규화합니다.
*/
export function normalizeTaskSeComCdForElapr(taskSeComCd?: string | null): string {
if (!taskSeComCd) return "";
const trimmed = taskSeComCd.trim();
if (trimmed === "SF_SP") return "SF_SP";
const codes = trimmed.split(",").map((c) => c.trim()).filter(Boolean);
if (codes.includes("SF") && codes.includes("SP")) {
return "SF_SP";
}
return trimmed;
}
/**
* 전자결재 복합병렬결재(Fork-Join) 대상 업무 여부 판단
*/
export function isForkJoinTask(taskSeComCd?: string | null): boolean {
return normalizeTaskSeComCdForElapr(taskSeComCd) === "SF_SP";
}
@@ -63,6 +63,10 @@ import {
buildTechReviewReplyPayload,
toSplmntDmndSaveItem,
} from "../../lib/save-payload-utils";
import {
isForkJoinTask,
normalizeTaskSeComCdForElapr,
} from "../../lib/fnrs-cnsltn-regist-schema";
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
@@ -179,6 +183,17 @@ function FloatingActionButtons({
elaprPrgrsForPending,
userInfo?.userUnqId,
);
const userPendingPrgrs = elaprPrgrsForPending.find(
(row) =>
row.aprvrUuid === userInfo?.userUnqId &&
row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
);
const userPendingBranchSeCd = (userPendingPrgrs as { branchSeCd?: string } | undefined)?.branchSeCd;
const userPendingAtrzSeComCd = userPendingPrgrs?.atrzSeComCd;
const normalizedTaskSeComCd = normalizeTaskSeComCdForElapr(data.detDto?.taskSeComCd);
const isForkJoin = isForkJoinTask(data.detDto?.taskSeComCd);
const viewOnlyPwrkCnsltnDoc =
prgrsSttsComCd === "SL" ||
(prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05") ||
@@ -449,6 +464,9 @@ function FloatingActionButtons({
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
taskSeComCd: normalizedTaskSeComCd || undefined,
forkJoinSeCd: isForkJoin ? "FORK_JOIN" : "NONE",
crntStageNo: isForkJoin ? 2 : 1,
elaprCycl: 1,
docPrgrsSttsComCd: "AC",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
@@ -468,6 +486,9 @@ function FloatingActionButtons({
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
taskSeComCd: normalizedTaskSeComCd || undefined,
forkJoinSeCd: isForkJoin ? "FORK_JOIN" : "NONE",
crntStageNo: isForkJoin ? 2 : 1,
elaprCycl: 2,
docPrgrsSttsComCd: "RK",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
@@ -776,12 +797,29 @@ function FloatingActionButtons({
)}
{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 onClick={() => setActiveDialog("elaprApproval")} disabled={isBusy}>
결재(회신)
<Button
color={
isForkJoin &&
userPendingBranchSeCd !== "TOTAL_AGG"
? "green"
: undefined
}
onClick={() => setActiveDialog("elaprApproval")}
disabled={isBusy}
>
{isForkJoin
? userPendingBranchSeCd === "TOTAL_AGG"
? "결재 (환경청에 최종회신)"
: "결재 (병합에 회신)"
: "결재(회신)"}
</Button>
)}
</>
@@ -795,6 +833,7 @@ function FloatingActionButtons({
open={activeDialog === "elaprTmplt"}
onClose={() => setActiveDialog(null)}
docSeComCd={docSeComCd}
taskSeComCd={normalizedTaskSeComCd || undefined}
onConfirm={handleElaprTmpltConfirm}
/>
@@ -827,6 +866,7 @@ function FloatingActionButtons({
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
elaprCycl={prgrsSttsComCd === "RK" ? 2 : undefined}
taskSeComCd={normalizedTaskSeComCd || undefined}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
@@ -21,6 +21,7 @@ import {
} from "../lib/ai-pending-utils";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { normalizeTaskSeComCdForElapr } from "../lib/fnrs-cnsltn-regist-schema";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
@@ -130,7 +131,10 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.fnrsCnsltnDocNo} />
<ElaprDocPrgrsStatus
elaprDocNo={detDto.fnrsCnsltnDocNo}
taskSeComCd={normalizeTaskSeComCdForElapr(detDto.taskSeComCd)}
/>
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<FnrsSharSection fnrsSharList={fnrsSharList} />
@@ -9,6 +9,7 @@ export const RVW_MNPW_BLOCKS: RvwMnpwBlockConfig[] = [
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ01" },
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ02" },
{ id: "approver", title: "승인", atrzSeComCd: "ATRZ03" },
{ id: "merger", title: "병합", atrzSeComCd: "ATRZ04" },
{ id: "final", title: "최종승인", atrzSeComCd: "ATRZ99" },
];
@@ -40,6 +40,7 @@ function RvwMnpwAsmtPanel({ selectedPerson }: RvwMnpwAsmtPanelProps) {
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
<Skeleton className="h-[8rem]" />
</div>
) : pageQuery.isError ? (
<p className="py-8 text-center text-lg text-muted-foreground">