[as elapr] 전자결재 forkjoin 통합 개발 화면
This commit is contained in:
@@ -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>이전(직전) 결재자만 '결재 대기중(ATRZ_PRGRS_002)'으로 변경되어 보완 및 재상신 기회가 부여됩니다.</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">
|
||||
|
||||
Reference in New Issue
Block a user