-
진행 단계
-
- {badges.map((badge, index) => (
-
- {index > 0 && >}
- {badge.label}
-
- ))}
-
+
+ {badges.map((badge, index) => (
+
+ {index > 0 && >}
+ {badge.label}
+
+ ))}
);
}
-export { ProgressStepIndicator };
+export function createFnrsCnsltnColumns() {
+ return [
+ columnHelper.accessor("sido", {
+ header: "시도",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("gugun", {
+ header: "시군구",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("bizNm", {
+ header: "사업명",
+ meta: { align: "left" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("taskSeCdNm", {
+ header: "업무구분",
+ meta: { align: "center" },
+ cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
+ }),
+ columnHelper.accessor("aplcntNm", {
+ header: "담당자",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("envoSeCdNm", {
+ header: "해당기관",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.display({
+ id: "progress",
+ header: "진행상황",
+ meta: { align: "center" },
+ cell: ({ row }) => (
+
+ ),
+ }),
+ columnHelper.accessor("regDt", {
+ header: "신청일",
+ meta: { align: "center" },
+ cell: ({ getValue }) => {
+ const value = getValue();
+ return value ? dayjs(value).format("YYYY-MM-DD") : "-";
+ },
+ }),
+ ];
+}
diff --git a/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts
new file mode 100644
index 000000000..2c9a72ef8
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts
@@ -0,0 +1,5 @@
+export interface ReviewerOption {
+ uuid: string;
+ name: string;
+ deptNm: string;
+}
diff --git a/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts b/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts
new file mode 100644
index 000000000..db60bd696
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts
@@ -0,0 +1,86 @@
+import type {
+ AtflMngClientResponseDto,
+ BizCstTreeDto,
+ BizSclTreeDto,
+ CklstRvwRsltTreeDto,
+ ElaprDocPrgrsClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+ FnrsCnsltnMngDetResponseDto,
+ FnrsCnsltnMngResponseDto,
+ FnrsCnsltnRvwOpnnResponseDto,
+} from "@/entities/fnrs-cnsltn";
+
+export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
+
+export type {
+ AtflMngClientResponseDto,
+ BizCstTreeDto,
+ BizSclTreeDto,
+ CklstRvwRsltTreeDto,
+ ElaprDocPrgrsClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+ FnrsCnsltnMngDetResponseDto,
+ FnrsCnsltnMngResponseDto,
+ FnrsCnsltnRvwOpnnResponseDto,
+};
+
+export type ChecklistRowKey = string;
+
+export interface ChecklistProgress {
+ total: number;
+ completed: number;
+ percent: number;
+}
+
+export const DEFAULT_CHECKLIST_PROGRESS: ChecklistProgress = {
+ total: 0,
+ completed: 0,
+ percent: 0,
+};
+
+export interface ChecklistFormState {
+ results: Record
;
+ comments: Record;
+ progress: ChecklistProgress;
+}
+
+export type ChecklistStepMap = Record;
+
+export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
+
+export const REVIEW_STEP_LABELS: Record = {
+ LOCAL: "지자체",
+ ENV: "환경청",
+ KECO: "환경공단",
+};
+
+export const TASK_CODE_LABELS: Record = {
+ RE: "재이용수",
+ SP: "하수관로",
+ SF: "하수처리시설",
+};
+
+export const PBADMS_PROGRESS_ORDER = [
+ "SV",
+ "SL",
+ "TA",
+ "ST",
+ "EP",
+ "LE",
+ "TP",
+ "ER",
+ "TE",
+ "TB",
+] as const;
+
+export const ROLE_TO_REVIEW_STEP: Record = {
+ ROLE_USER05: "LOCAL",
+ ROLE_USER03: "ENV",
+ ROLE_USER01: "KECO",
+};
+
+export const REVIEW_STEP_ROLE_RANK: Record = {
+ LOCAL: 1,
+ ENV: 2,
+ KECO: 3,
+};
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx
new file mode 100644
index 000000000..dd241a514
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { RvwMnpwMngResponseDto, useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { type ReviewerOption } from "../../model/reviewer-option";
+
+const PAGE_SIZE = 500;
+
+interface AprvrPickerDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ atrzSeComCd: string;
+ onSelect: (reviewer: ReviewerOption) => void;
+}
+
+function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrPickerDialogProps) {
+ const { data, isPending, isError } = useRvwMnpwMngPage(
+ {
+ atrzSeComCd,
+ page: 0,
+ size: PAGE_SIZE,
+ sort: "userUuidNm,asc",
+ },
+ { enabled: open },
+ );
+
+ const items = data?.content ?? [];
+
+ function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption {
+ return {
+ uuid: item.userUuid,
+ name: item.userUuidNm ?? "",
+ deptNm: item.ognzNm ?? "",
+ };
+ }
+
+ function handleSelect(item: (typeof items)[number]) {
+ onSelect(fromRvwMnpwMng(item));
+ onOpenChange(false);
+ }
+
+ return (
+
+ );
+}
+
+export { AprvrPickerDialog };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx
new file mode 100644
index 000000000..4e0100089
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx
@@ -0,0 +1,164 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
+import {
+ cn,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import { buildChecklistRowKey, patchChecklistFormState } from "../../lib/checklist-utils";
+import type { ReviewStepCode } from "../../model/view-types";
+import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types";
+
+interface ChecklistTaskPanelProps {
+ selectedReviewStep: ReviewStepCode;
+ selectedRows: CklstRvwRsltTreeDto[];
+ allRows: CklstRvwRsltTreeDto[];
+ formState: ChecklistFormState;
+ setFormState: Dispatch>;
+ userRoleCode?: string;
+}
+
+function ChecklistTaskPanel({
+ selectedReviewStep,
+ selectedRows,
+ allRows,
+ formState,
+ setFormState,
+ userRoleCode,
+}: ChecklistTaskPanelProps) {
+ const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined;
+ const isStepEditable = currentUserReviewStep === selectedReviewStep;
+
+ return (
+ <>
+
+
+
+
+
+ 검토항목
+
+ 주요 검토내용
+ 검토 결과
+ {selectedReviewStep !== "LOCAL" ? (
+
+ 기술검토 의견
+
+ ) : null}
+
+
+ 예
+ 아니오
+
+
+
+ {selectedRows.length === 0 ? (
+
+
+ 조회된 체크리스트 항목이 없습니다.
+
+
+ ) : (
+ selectedRows.map((row) => {
+ const key = buildChecklistRowKey(row, selectedReviewStep);
+ const isFieldVisible = row.bchckUseYn === "Y";
+ const isLocalReviewStep = selectedReviewStep === "LOCAL";
+
+ return (
+
+ {row.cklstArtclNm}
+
+ {row.rvwCn}
+
+ {isFieldVisible && (
+ <>
+
+ {
+ if (!isStepEditable) return;
+ setFormState((prev) =>
+ patchChecklistFormState(
+ prev,
+ { results: { ...prev.results, [key]: "Y" } },
+ allRows,
+ selectedReviewStep,
+ ),
+ );
+ }}
+ className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
+ />
+
+
+ {
+ if (!isStepEditable) return;
+ setFormState((prev) =>
+ patchChecklistFormState(
+ prev,
+ { results: { ...prev.results, [key]: "N" } },
+ allRows,
+ selectedReviewStep,
+ ),
+ );
+ }}
+ className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
+ />
+
+ >
+ )}
+ {!isLocalReviewStep && isFieldVisible && (
+
+
+ )}
+
+ );
+ })
+ )}
+
+
+
+ >
+ );
+}
+
+export { ChecklistTaskPanel };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx
new file mode 100644
index 000000000..a22a2e880
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useEffect, 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 {
+ type ElaprAplnRow,
+ useAplnRows,
+ useDndSensors,
+ useElaprAplnPage,
+ validateAplnRow,
+} from "@/entities/elapr";
+import {
+ Button,
+ Skeleton,
+ Table,
+ TableBody,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import type { ReviewerOption } from "../../model/reviewer-option";
+
+import { AprvrPickerDialog } from "./aprvr-picker-dialog";
+import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
+
+interface ElaprAplnPickerGridProps {
+ elaprTmpltId: string | null;
+ onRowsChange?: (rows: ElaprAplnRow[]) => void;
+ onEditingLockedChange?: (locked: boolean) => void;
+}
+
+function ElaprAplnPickerGrid({
+ elaprTmpltId,
+ onRowsChange,
+ onEditingLockedChange,
+}: ElaprAplnPickerGridProps) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [pickingRowKey, setPickingRowKey] = useState(null);
+
+ const alert = useAlertStore((state) => state.setMessage);
+
+ const aplnQuery = useElaprAplnPage({
+ elaprTmpltId: elaprTmpltId ?? undefined,
+ page: 0,
+ size: 200,
+ sort: "atrzSeqNo,asc",
+ });
+
+ const {
+ rows,
+ visibleRows,
+ editingRowKey,
+ isEditingLocked,
+ addRow,
+ startEdit,
+ updateRow,
+ cancelEdit,
+ commitEdit,
+ markDeleted,
+ reorderRows,
+ } = useAplnRows({
+ elaprTmpltId,
+ serverRows: aplnQuery.data?.content,
+ });
+
+ useEffect(() => {
+ onRowsChange?.(rows);
+ }, [rows, onRowsChange]);
+
+ useEffect(() => {
+ onEditingLockedChange?.(isEditingLocked);
+ }, [isEditingLocked, onEditingLockedChange]);
+
+ const sensors = useDndSensors();
+ const isDisabled = !elaprTmpltId;
+ const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ reorderRows(String(active.id), String(over.id));
+ }
+
+ function handleRowChange(row: ElaprAplnRow, patch: Partial) {
+ updateRow(row.rowKey, patch);
+ }
+
+ function handleRowSave(row: ElaprAplnRow) {
+ const fieldError = validateAplnRow(row);
+ if (fieldError) {
+ alert(fieldError);
+ return;
+ }
+
+ commitEdit(row.rowKey);
+ }
+
+ function handleRowDelete(row: ElaprAplnRow) {
+ markDeleted(row.rowKey);
+ }
+
+ function handleReviewerSelect(reviewer: ReviewerOption) {
+ if (!pickingRowKey) return;
+ updateRow(pickingRowKey, {
+ aprvrUuid: reviewer.uuid,
+ aprvrNm: reviewer.name,
+ });
+ setPickingRowKey(null);
+ }
+
+ return (
+
+
+
결재선 관리
+
+
+
+
+
+ {aplnQuery.isPending && elaprTmpltId ? (
+
+
+
+
+ ) : !elaprTmpltId ? (
+ 결재서식을 선택하세요.
+ ) : visibleRows.length === 0 ? (
+
+ 등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 결재선아이디
+ 결재순서
+ 결재구분
+ 결재자
+ 관리
+
+
+
+ r.rowKey)}
+ strategy={verticalListSortingStrategy}
+ >
+ {visibleRows.map((row, index) => (
+ startEdit(row.rowKey)}
+ onSave={() => handleRowSave(row)}
+ onCancel={cancelEdit}
+ onDelete={() => handleRowDelete(row)}
+ onChange={(patch) => handleRowChange(row, patch)}
+ onPickAprvr={() => {
+ setPickingRowKey(row.rowKey);
+ setPickerOpen(true);
+ }}
+ />
+ ))}
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+export { ElaprAplnPickerGrid };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx
new file mode 100644
index 000000000..a6737ec3e
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx
@@ -0,0 +1,419 @@
+"use client";
+
+import { useState } from "react";
+
+import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
+import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
+import { useQueryClient } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+import { ENDPOINT } from "@/shared/config";
+import { useAlertStore } from "@/shared/lib";
+import {
+ buildPrgrsSttsSavePayload,
+ elaprPrgrsSttsService,
+ findCurrentUserPendingApprovalRequest,
+ hasUnsavedPrgrsSttsChanges,
+ isPrgrsSttsRowEditable,
+ useDndSensors,
+ useElaprDocPrgrsByDocNo,
+ usePrgrsSttsRows,
+ useSaveElaprPrgrsStts,
+ validateAllPrgrsSttsRows,
+ validateAtrzOrder,
+ validatePrgrsSttsRow,
+} from "@/entities/elapr";
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Label,
+ Skeleton,
+ Table,
+ TableBody,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import type { ReviewerOption } from "../../model/reviewer-option";
+
+import { AprvrPickerDialog } from "./aprvr-picker-dialog";
+import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
+
+interface ElaprApprovalDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ elaprDocNo: string;
+ currentUserUuid: string;
+ onSuccess: () => void;
+}
+
+const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요.";
+
+function ElaprApprovalDialog({
+ open,
+ onOpenChange,
+ elaprDocNo,
+ currentUserUuid,
+ onSuccess,
+}: ElaprApprovalDialogProps) {
+ const queryClient = useQueryClient();
+ const alert = useAlertStore((state) => state.setMessage);
+ const [rjctRsn, setRjctRsn] = useState("");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [pickingRowKey, setPickingRowKey] = useState(null);
+ const [finalApprovalYn, setFinalApprovalYn] = useState(false);
+
+ const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
+
+ const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
+
+ const {
+ rows,
+ visibleRows,
+ editingRowKey,
+ isEditingLocked,
+ addRow,
+ startEdit,
+ updateRow,
+ cancelEdit,
+ commitEdit,
+ markDeleted,
+ reorderRows,
+ } = usePrgrsSttsRows({
+ serverRows: elaprDocPrgrsList,
+ elaprDmndId,
+ });
+
+ const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
+ const sensors = useDndSensors();
+
+ const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
+ elaprDocPrgrsList,
+ currentUserUuid,
+ );
+ const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
+
+ const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
+ const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
+ const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ reorderRows(String(active.id), String(over.id));
+ }
+
+ function handleRowSave(rowKey: string) {
+ const row = rows.find((r) => r.rowKey === rowKey);
+ if (!row) return;
+
+ const fieldError = validatePrgrsSttsRow(row);
+ if (fieldError) {
+ alert(fieldError);
+ return;
+ }
+
+ commitEdit(rowKey);
+ }
+
+ function handleReviewerSelect(reviewer: ReviewerOption) {
+ if (!pickingRowKey) return;
+ updateRow(pickingRowKey, {
+ aprvrUuid: reviewer.uuid,
+ aprvrNm: reviewer.name,
+ ognzNm: reviewer.deptNm,
+ });
+ setPickingRowKey(null);
+ }
+
+ function assertPrgrsSttsSaved(): boolean {
+ if (isEditingLocked) {
+ alert(UNSAVED_PRGRS_STTS_MESSAGE);
+ return false;
+ }
+
+ if (isPrgrsSttsUnsaved) {
+ alert(UNSAVED_PRGRS_STTS_MESSAGE);
+ return false;
+ }
+
+ return true;
+ }
+
+ async function handleSavePrgrsStts() {
+ if (!elaprDmndId) {
+ alert("결재 요청 정보가 없습니다.");
+ return;
+ }
+
+ const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked);
+ if (validationError) {
+ alert(validationError);
+ return;
+ }
+
+ const orderError = validateAtrzOrder(rows);
+ if (orderError) {
+ alert(orderError);
+ return;
+ }
+
+ const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
+ if (payload.length === 0) {
+ alert("변경된 결재선이 없습니다.");
+ return;
+ }
+
+ setIsProcessing(true);
+ try {
+ await savePrgrsSttsMutation.mutateAsync(payload);
+ alert("결재선이 저장되었습니다.");
+ await queryClient.invalidateQueries({
+ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
+ });
+ onSuccess();
+ } catch (error: unknown) {
+ let errorMessage = "결재선 저장 중 오류가 발생했습니다.";
+ if (error instanceof AxiosError) {
+ errorMessage = error.response?.data?.message ?? errorMessage;
+ }
+ alert(errorMessage);
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ async function handleApprove() {
+ if (!currentUserPrgrs?.elaprPrgrsId) return;
+ if (!assertPrgrsSttsSaved()) return;
+
+ setIsProcessing(true);
+ try {
+ await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
+ atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
+ finalApprovalYn,
+ });
+ alert("결재가 완료되었습니다.");
+ queryClient.invalidateQueries({
+ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
+ });
+ onSuccess();
+ onOpenChange(false);
+ } catch (error: unknown) {
+ let errorMessage = "";
+ if (error instanceof AxiosError) {
+ errorMessage = error.response?.data?.message ?? "";
+ } else {
+ errorMessage = "결재 처리 중 오류가 발생했습니다.";
+ }
+ alert(errorMessage);
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ async function handleReject() {
+ if (!currentUserPrgrs?.elaprPrgrsId) return;
+ if (!rjctRsn.trim()) {
+ alert("반려 사유를 입력해주세요.");
+ return;
+ }
+ if (!assertPrgrsSttsSaved()) return;
+
+ setIsProcessing(true);
+ try {
+ await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
+ atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
+ rjctRsn: rjctRsn.trim(),
+ });
+ alert("반려 처리가 완료되었습니다.");
+ queryClient.invalidateQueries({
+ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
+ });
+ onSuccess();
+ onOpenChange(false);
+ } catch (error: unknown) {
+ let errorMessage = "";
+ if (error instanceof AxiosError) {
+ errorMessage = error.response?.data?.message ?? "";
+ } else {
+ errorMessage = "반려 처리 중 오류가 발생했습니다.";
+ }
+ alert(errorMessage);
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ function handleClose() {
+ setRjctRsn("");
+ setPickingRowKey(null);
+ onOpenChange(false);
+ }
+
+ return (
+
+ );
+}
+
+export { ElaprApprovalDialog };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx
new file mode 100644
index 000000000..3f59f602b
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+import { useAlertStore } from "@/shared/lib";
+import {
+ type ElaprAplnRow,
+ type ElaprTmplt,
+ getVisibleRows,
+ useElaprTmpltPage,
+ validateAllRowsForSave,
+ validateAtrzOrder,
+ validateUniqueSeqNo,
+} from "@/entities/elapr";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@common/components";
+
+import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
+import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
+
+const PAGE_SIZE = 100;
+
+interface ElaprTmpltPickerConfirmPayload {
+ tmplt: ElaprTmplt;
+ aplnRows: ElaprAplnRow[];
+}
+
+interface ElaprTmpltPickerDialogProps {
+ open: boolean;
+ onClose: () => void;
+ onSelect?: (tmplt: ElaprTmplt) => void;
+ onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
+}
+
+function ElaprTmpltPickerDialog({
+ open,
+ onClose,
+ onSelect,
+ onConfirm,
+}: ElaprTmpltPickerDialogProps) {
+ const alert = useAlertStore((state) => state.setMessage);
+ const [selectedTmplt, setSelectedTmplt] = useState(null);
+ const [aplnRows, setAplnRows] = useState([]);
+ const [isEditingLocked, setIsEditingLocked] = useState(false);
+
+ const tmpltQuery = useElaprTmpltPage({
+ page: 0,
+ size: PAGE_SIZE,
+ sort: "regDt,desc",
+ docSeComCd: "FNRS",
+ });
+
+ const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
+ setAplnRows(rows);
+ }, []);
+
+ const handleEditingLockedChange = useCallback((locked: boolean) => {
+ setIsEditingLocked(locked);
+ }, []);
+
+ function handleSelectTmplt(tmplt: ElaprTmplt | null) {
+ setSelectedTmplt(tmplt);
+ setAplnRows([]);
+ setIsEditingLocked(false);
+ }
+
+ function handleOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ setSelectedTmplt(null);
+ setAplnRows([]);
+ setIsEditingLocked(false);
+ onClose();
+ }
+ }
+
+ function handleConfirm() {
+ if (!selectedTmplt) {
+ alert("결재서식을 선택해 주세요.");
+ return;
+ }
+
+ const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
+ if (rowsError) {
+ alert(rowsError);
+ return;
+ }
+
+ const orderError = validateAtrzOrder(aplnRows);
+ if (orderError) {
+ alert(orderError);
+ return;
+ }
+
+ const seqError = validateUniqueSeqNo(aplnRows);
+ if (seqError) {
+ alert(seqError);
+ return;
+ }
+
+ const visibleRows = getVisibleRows(aplnRows);
+
+ const payload: ElaprTmpltPickerConfirmPayload = {
+ tmplt: selectedTmplt,
+ aplnRows: visibleRows,
+ };
+
+ if (onConfirm) {
+ onConfirm(payload);
+ } else {
+ onSelect?.(selectedTmplt);
+ }
+
+ setSelectedTmplt(null);
+ setAplnRows([]);
+ setIsEditingLocked(false);
+ onClose();
+ }
+
+ return (
+
+ );
+}
+
+export { ElaprTmpltPickerDialog };
+export type { ElaprTmpltPickerConfirmPayload };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx
new file mode 100644
index 000000000..50bdb33b9
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+
+import type { UseQueryResult } from "@tanstack/react-query";
+import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
+
+import { useDataTable } from "@/shared/lib";
+import type { PageResponse } from "@/shared/model";
+import type { ElaprTmplt } from "@/entities/elapr";
+import { Skeleton } from "@common/components";
+
+const columnHelper = createColumnHelper();
+
+interface ElaprTmpltSelectTableProps {
+ query: UseQueryResult>;
+ selectedTmpltId: string | null;
+ onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
+}
+
+function ElaprTmpltSelectTable({
+ query,
+ selectedTmpltId,
+ onSelectTmplt,
+}: ElaprTmpltSelectTableProps) {
+ const [rowSelection, setRowSelection] = useState({});
+
+ useEffect(() => {
+ if (selectedTmpltId) {
+ setRowSelection({ [selectedTmpltId]: true });
+ } else {
+ setRowSelection({});
+ }
+ }, [selectedTmpltId]);
+
+ const columns = useMemo(
+ () => [
+ columnHelper.accessor("elaprTmpltId", {
+ header: "전자결재서식아이디",
+ meta: { align: "left" },
+ }),
+ columnHelper.accessor("elaprTmpltNm", {
+ header: "결재서식명",
+ meta: { align: "left" },
+ }),
+ columnHelper.accessor("docSeComNm", {
+ header: "문서구분",
+ cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
+ }),
+ ],
+ [],
+ );
+
+ const { tableComponent } = useDataTable({
+ query,
+ tableOptions: {
+ columns,
+ state: { rowSelection },
+ enableRowSelection: true,
+ enableMultiRowSelection: false,
+ getRowId: (row) => row.elaprTmpltId,
+ onRowSelectionChange: (updater) => {
+ const next = typeof updater === "function" ? updater(rowSelection) : updater;
+ setRowSelection(next);
+ const selectedId = Object.keys(next)[0];
+ if (!selectedId) {
+ onSelectTmplt(null);
+ return;
+ }
+ const selected = query.data?.content.find((t) => t.elaprTmpltId === selectedId);
+ onSelectTmplt(selected ?? null);
+ },
+ getCoreRowModel: getCoreRowModel(),
+ },
+ dataTableOptions: {
+ onRowClick: (row) => {
+ setRowSelection({ [row.elaprTmpltId]: true });
+ onSelectTmplt(row);
+ },
+ },
+ });
+
+ return (
+
+
+
전자결재서식
+
+ {query.isPending && (
+
+
+
+
+ )}
+ {query.isError && (
+
+ 결재서식 목록을 불러오지 못했습니다.
+
+ )}
+ {!query.isPending && !query.isError && tableComponent}
+
+ );
+}
+
+export { ElaprTmpltSelectTable };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx
new file mode 100644
index 000000000..bf3d02823
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx
@@ -0,0 +1,670 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { useQueryClient } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+import { ENDPOINT } from "@/shared/config";
+import { useAlertStore, useUserStore } from "@/shared/lib";
+import {
+ buildElaprPrgrsSttsListFromAplnRows,
+ type ElaprDmndSaveRequestDto,
+ elaprDmndService,
+ hasCurrentUserPendingApprovalRequest,
+ useElaprDocPrgrsByDocNo,
+} from "@/entities/elapr";
+import {
+ type DocPrgrsUpdateRequestDto,
+ type FnrsCnsltnApplySaveRequestDto,
+ fnrsCnsltnMngBaseKey,
+ type FnrsCnsltnMngDetResponseDto,
+ fnrsCnsltnMngService,
+} from "@/entities/fnrs-cnsltn";
+import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
+import { TextInputDialog } from "@/widgets/pbadms-sprt";
+import { Button } from "@common/components";
+
+import {
+ FNRS_CNSLTN_VIEW_BUTTON_ACTION,
+ type FnrsCnsltnViewButtonAction,
+ getButtonConfigs,
+ needsRoleUser01AtrzResolve,
+} from "../../lib/button-config";
+import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
+import {
+ buildSavePayload,
+ buildSupplementReqPayload,
+ buildSupplementSubmitPayload,
+ buildTechReviewReplyPayload,
+} from "../../lib/save-payload-utils";
+import { type ChecklistFormState } from "../../model/view-types";
+import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
+
+import { ElaprApprovalDialog } from "./elapr-approval-dialog";
+import {
+ type ElaprTmpltPickerConfirmPayload,
+ ElaprTmpltPickerDialog,
+} from "./elapr-tmplt-picker-dialog";
+
+interface FloatingActionButtonsProps {
+ data: FnrsCnsltnMngDetResponseDto;
+ docSeComCd: string;
+ fnrsCnsltnDocNo: string;
+ prgrsSttsComCd: string;
+ dtlInstCd: string | undefined;
+ checklistFormState: ChecklistFormState;
+ techReviewOpinionRef?: React.RefObject;
+ opnrNm?: string;
+}
+
+type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null;
+
+function FloatingActionButtons({
+ data,
+ docSeComCd,
+ fnrsCnsltnDocNo,
+ prgrsSttsComCd,
+ dtlInstCd,
+ checklistFormState,
+ techReviewOpinionRef,
+ opnrNm,
+}: FloatingActionButtonsProps) {
+ const router = useRouter();
+ const { confirm } = useConfirm();
+ const queryClient = useQueryClient();
+ const userInfo = useUserStore((state) => state.userInfo);
+ const alert = useAlertStore((state) => state.setMessage);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [activeDialog, setActiveDialog] = useState(null);
+
+ const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo;
+ const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
+
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
+ const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
+ const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
+ {},
+ { enabled: needsAtrzResolve },
+ );
+ const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
+ useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
+
+ const atrzSeComCdList = useMemo(
+ () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
+ [rvwLoginUserData],
+ );
+
+ const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
+ elaprDocPrgrsList,
+ userInfo?.userUnqId,
+ );
+
+ const actionButtons = useMemo(() => {
+ if (needsAtrzResolve && isRvwLoginUserLoading) return [];
+ if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
+ return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
+ hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
+ });
+ }, [
+ atrzSeComCdList,
+ dtlInstCd,
+ hasPendingApprovalRequest,
+ isElaprDocPrgrsLoading,
+ isRvwLoginUserLoading,
+ needsAtrzResolve,
+ needsTeApprovalCheck,
+ prgrsSttsComCd,
+ ]);
+
+ const canEdit =
+ isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
+
+ function handleGoToList() {
+ router.push("/fnrs-cnsltn");
+ }
+
+ function handleEdit() {
+ router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
+ }
+
+ function shouldShowButton(action: FnrsCnsltnViewButtonAction) {
+ return actionButtons.some((button) => button === action);
+ }
+
+ async function executeAction({
+ action,
+ request,
+ successMessage,
+ }: {
+ action: FnrsCnsltnViewButtonAction;
+ request?: unknown;
+ successMessage?: string;
+ }) {
+ const handler = buttonActionHandlers[action];
+ if (!handler) return;
+
+ setIsProcessing(true);
+ try {
+ await handler({
+ request,
+ data,
+ onSuccess: () => {
+ if (successMessage) alert(successMessage);
+ queryClient.invalidateQueries({
+ queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
+ });
+ },
+ onError: (error) => {
+ let errorMessage = "";
+ if (error instanceof AxiosError) {
+ errorMessage = error.response?.data?.message ?? "";
+ } else {
+ errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
+ }
+ alert(errorMessage);
+ },
+ });
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ async function handleStrgClick() {
+ const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload(
+ data,
+ checklistFormState,
+ "LOCAL",
+ "SV",
+ );
+
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg,
+ request: payload,
+ successMessage: "저장되었습니다.",
+ });
+ }
+
+ async function handleAplyClick() {
+ const { percent } = computeReviewStepChecklistProgress(
+ data.cklstResultList ?? [],
+ checklistFormState.results,
+ "LOCAL",
+ );
+ const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload(
+ data,
+ checklistFormState,
+ "LOCAL",
+ "ST",
+ );
+
+ if (percent !== 100) {
+ alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
+ return;
+ }
+
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply,
+ request: payload,
+ successMessage: "신청 완료되었습니다.",
+ });
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ }
+
+ async function handleDelClick() {
+ if (!(await confirm("삭제하시겠습니까?"))) return;
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
+ successMessage: "삭제되었습니다.",
+ });
+ router.push("/fnrs-cnsltn");
+ }
+
+ async function handleSplmntCmptnClick() {
+ const payload = buildSupplementSubmitPayload(data, checklistFormState);
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn,
+ request: payload,
+ successMessage: "보완완료 처리되었습니다.",
+ });
+ }
+
+ async function handleFnrsCnsltnAncmntClick() {
+ const payload: DocPrgrsUpdateRequestDto = {
+ docNo,
+ docSeComCd,
+ prgrsSttsComCd: "TB",
+ };
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt,
+ request: payload,
+ successMessage: "고시되었습니다.",
+ });
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ }
+
+ async function handleEnvoSbmsnClick() {
+ const payload: DocPrgrsUpdateRequestDto = {
+ docNo,
+ docSeComCd,
+ prgrsSttsComCd: "LE",
+ };
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn,
+ request: payload,
+ successMessage: "제출되었습니다.",
+ });
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ }
+
+ async function handleRjctClick() {
+ const payload: DocPrgrsUpdateRequestDto = {
+ docNo,
+ docSeComCd,
+ prgrsSttsComCd: "SV",
+ };
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct,
+ request: payload,
+ successMessage: "반려되었습니다.",
+ });
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ }
+
+ async function handleSncnAprvClick() {
+ const payload: DocPrgrsUpdateRequestDto = {
+ docNo,
+ docSeComCd,
+ prgrsSttsComCd: "TA",
+ };
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv,
+ request: payload,
+ successMessage: "인가승인되었습니다.",
+ });
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ }
+
+ function getReviewOpinionFormData() {
+ return techReviewOpinionRef?.current?.getFormData() ?? null;
+ }
+
+ async function handleRvwRplyClick() {
+ const { percent } = computeReviewStepChecklistProgress(
+ data.cklstResultList ?? [],
+ checklistFormState.results,
+ "ENV",
+ );
+
+ if (percent !== 100) {
+ alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
+ return;
+ }
+
+ const payload = buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "ENV",
+ "EP",
+ "CP",
+ getReviewOpinionFormData(),
+ opnrNm,
+ );
+
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply,
+ request: payload,
+ successMessage: "검토결과 회신이 완료되었습니다.",
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ }
+
+ async function handleRqstTechRvwClick() {
+ const payload: DocPrgrsUpdateRequestDto = {
+ docNo,
+ docSeComCd,
+ prgrsSttsComCd: "ER",
+ };
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw,
+ request: payload,
+ successMessage: "기술검토 의뢰가 완료되었습니다.",
+ });
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ }
+
+ async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
+ const payload: ElaprDmndSaveRequestDto = {
+ elaprDocNo: docNo,
+ dmndTtl: data.detDto?.bizNm ?? "",
+ dmndCn: data.detDto?.bizCn ?? "",
+ elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
+ };
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
+ request: payload,
+ successMessage: "검토자가 지정되었습니다.",
+ });
+ await queryClient.invalidateQueries({
+ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
+ });
+ }
+
+ async function handleRvwStrgClick() {
+ const payload = buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "KECO",
+ "TE",
+ "SV",
+ getReviewOpinionFormData(),
+ opnrNm,
+ );
+
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg,
+ request: payload,
+ successMessage: "저장되었습니다.",
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ }
+
+ async function handleRvwCmptnClick() {
+ const { percent } = computeReviewStepChecklistProgress(
+ data.cklstResultList ?? [],
+ checklistFormState.results,
+ "KECO",
+ );
+
+ if (percent !== 100) {
+ alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
+ return;
+ }
+
+ const payload = buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "KECO",
+ "TE",
+ "SV",
+ getReviewOpinionFormData(),
+ opnrNm,
+ );
+
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
+ request: payload,
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ setActiveDialog("elaprApproval");
+ }
+
+ function handleDmndSplmntClick() {
+ setActiveDialog("dmndSplmnt");
+ }
+
+ async function handleDmndSplmntConfirm(splmntDmndCn: string) {
+ setActiveDialog(null);
+ const payload = buildSupplementReqPayload(data, splmntDmndCn);
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
+ request: payload,
+ successMessage: "보완요청이 처리되었습니다.",
+ });
+ }
+
+ return (
+ <>
+
+
+
+ {canEdit && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (
+
+ )}
+ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
+
+ )}
+
+
+
+ setActiveDialog(null)}
+ onConfirm={handleElaprTmpltConfirm}
+ />
+
+ setActiveDialog(null)}
+ title="보완요청 내용 입력"
+ placeholder="보완요청 내용을 입력하세요."
+ isSubmitting={isProcessing}
+ onConfirm={handleDmndSplmntConfirm}
+ />
+
+ setActiveDialog(open ? "elaprApproval" : null)}
+ elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
+ currentUserUuid={userInfo?.userUnqId ?? ""}
+ onSuccess={() => {
+ queryClient.invalidateQueries({
+ queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
+ });
+ }}
+ />
+ >
+ );
+}
+
+export { FloatingActionButtons };
+
+async function handleSaveApply(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await fnrsCnsltnMngService.saveApply(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleDel(context: ActionHandlerContext): Promise {
+ try {
+ await fnrsCnsltnMngService.remove(context.data.detDto?.fnrsCnsltnDocNo ?? "");
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleSplmntCmptn(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ const docNo = context.data.detDto?.fnrsCnsltnDocNo;
+ if (!docNo) throw new Error("문서번호가 없습니다.");
+ await fnrsCnsltnMngService.submitSupplement(docNo, context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleChangePrgrsSttsComCd(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await fnrsCnsltnMngService.changePrgrsSttsComCd(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleTechReview(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await fnrsCnsltnMngService.techReview(
+ context.data.detDto?.fnrsCnsltnDocNo ?? "",
+ context.request,
+ );
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleDsgntRvwr(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await elaprDmndService.save(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleDmndSplmnt(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ const docNo = context.data.detDto?.fnrsCnsltnDocNo;
+ if (!docNo) throw new Error("문서번호가 없습니다.");
+ await fnrsCnsltnMngService.submitReqSupplement(docNo, context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleAtrzAprv(context: ActionHandlerContext): Promise {
+ // TODO: 결재 승인 처리 API 호출
+ context.onSuccess?.();
+}
+
+async function handleAtrzRply(context: ActionHandlerContext): Promise {
+ // TODO: 결재 회신 처리 API 호출
+ context.onSuccess?.();
+}
+
+async function handleGrndsChck(context: ActionHandlerContext): Promise {
+ // TODO: 현장점검 등록 API 호출
+ context.onSuccess?.();
+}
+
+const buttonActionHandlers: Record = {
+ strg: handleSaveApply,
+ aply: handleSaveApply,
+ del: handleDel,
+ splmntCmptn: handleSplmntCmptn,
+ fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd,
+ envoSbmsn: handleChangePrgrsSttsComCd,
+ rjct: handleChangePrgrsSttsComCd,
+ sncnAprv: handleChangePrgrsSttsComCd,
+ rvwRply: handleTechReview,
+ rqstTechRvw: handleChangePrgrsSttsComCd,
+ dsgntRvwr: handleDsgntRvwr,
+ rvwStrg: handleTechReview,
+ rvwCmptn: handleTechReview,
+ dmndSplmnt: handleDmndSplmnt,
+ atrzAprv: handleAtrzAprv,
+ atrzRply: handleAtrzRply,
+ grndsChck: handleGrndsChck,
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+interface ActionHandlerContext {
+ request: T;
+ data: FnrsCnsltnMngDetResponseDto;
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+}
+
+type ActionHandler = (context: ActionHandlerContext) => Promise