-
진행 단계
-
- {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/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/checklist-task-panel.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx
new file mode 100644
index 000000000..1e4d186e6
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx
@@ -0,0 +1,202 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
+import {
+ Button,
+ 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;
+
+ function handleSelectAll(value: "Y" | "N") {
+ if (!isStepEditable) return;
+ setFormState((prev) => {
+ const nextResults = { ...prev.results };
+ selectedRows
+ .filter((row) => row.bchckUseYn === "Y")
+ .forEach((row) => {
+ nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
+ });
+ return patchChecklistFormState(
+ prev,
+ { results: nextResults },
+ allRows,
+ 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/floating-action-buttons.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx
new file mode 100644
index 000000000..d55333763
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx
@@ -0,0 +1,635 @@
+"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,
+ ElaprApprovalDialog,
+ type ElaprDmndSaveRequestDto,
+ elaprDmndService,
+ type ElaprTmpltPickerConfirmPayload,
+ ElaprTmpltPickerDialog,
+ 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 { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } 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,
+ buildTechReviewReplyPayload,
+ toSplmntDmndSaveItem,
+} from "../../lib/save-payload-utils";
+import { type ChecklistFormState } from "../../model/view-types";
+import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
+
+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 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,
+ ),
+ splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
+ };
+
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
+ request: payload,
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ setActiveDialog("elaprApproval");
+ }
+
+ function handleDmndSplmntClick() {
+ setActiveDialog("dmndSplmnt");
+ }
+
+ async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
+ setActiveDialog(null);
+ const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
+ await executeAction({
+ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
+ request: requestPayload,
+ 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.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)}
+ docSeComCd={docSeComCd}
+ onConfirm={handleElaprTmpltConfirm}
+ />
+
+ setActiveDialog(null)}
+ 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 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?.();
+}
+
+const buttonActionHandlers: Record = {
+ strg: handleSaveApply,
+ aply: handleSaveApply,
+ del: handleDel,
+ fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd,
+ envoSbmsn: handleChangePrgrsSttsComCd,
+ rjct: handleChangePrgrsSttsComCd,
+ sncnAprv: handleChangePrgrsSttsComCd,
+ rvwRply: handleTechReview,
+ rqstTechRvw: handleChangePrgrsSttsComCd,
+ dsgntRvwr: handleDsgntRvwr,
+ rvwStrg: handleTechReview,
+ rvwCmptn: handleTechReview,
+ dmndSplmnt: handleDmndSplmnt,
+ atrzAprv: handleAtrzAprv,
+ atrzRply: handleAtrzRply,
+};
+
+// 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;
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx
new file mode 100644
index 000000000..f3fc0090a
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
+import { Skeleton } from "@common/components";
+
+import { FnrsFormPage } from "./fnrs-form-page";
+
+interface FnrsEditPageProps {
+ id: string;
+}
+
+function FnrsEditPage({ id }: FnrsEditPageProps) {
+ const { data, isLoading } = useFnrsCnsltnMng(id);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!data?.detDto) {
+ return 상세 정보를 찾을 수 없습니다.
;
+ }
+
+ return ;
+}
+
+export { FnrsEditPage };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx
new file mode 100644
index 000000000..76c42f38f
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx
@@ -0,0 +1,330 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { useRouter } from "next/navigation";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQueryClient } from "@tanstack/react-query";
+
+import { useAlertStore, useUserStore } from "@/shared/lib";
+import { useAcntgSeMngTree } from "@/entities/acntg";
+import {
+ type FnrsCnsltnApplySaveRequestDto,
+ fnrsCnsltnMngBaseKey,
+ type FnrsCnsltnMngDetResponseDto,
+ fnrsCnsltnMngService,
+} from "@/entities/fnrs-cnsltn";
+import { useFnrsSharSeMngTree } from "@/entities/fnrs-shar-se";
+import {
+ AttachmentsFormSection,
+ buildAcntgCostItems,
+ buildFnrsSharItems,
+ BusinessCostFormSection,
+ BusinessScaleFormSection,
+ ChecklistFormSection,
+ ContactInfoFormSection,
+ FnrsSharFormSection,
+ isFormChecklistComplete,
+ PrivacyConsentSection,
+ recalculateFnrsSharTotals,
+ SplmntDmndFormSection,
+ toFnrsSharSaveList,
+ validateBizCstSharBalance,
+} from "@/widgets/pbadms-sprt";
+import { Button } from "@common/components";
+
+import {
+ type FnrsCnsltnRegistFormData,
+ fnrsCnsltnRegistSchema,
+ formatTaskSeComCdForApi,
+} from "../lib/fnrs-cnsltn-regist-schema";
+import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
+
+import { BasicInfoFormSection } from "./sections/basic-info-form-section";
+interface FnrsFormPageProps {
+ id?: string;
+ mode: "create" | "edit";
+ initialData?: FnrsCnsltnMngDetResponseDto;
+ taskSeComCd?: string[];
+}
+
+function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) {
+ const queryClient = useQueryClient();
+ const router = useRouter();
+ const { confirm } = useConfirm();
+ const userInfo = useUserStore((state) => state.userInfo);
+ const alert = useAlertStore((state) => state.setMessage);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const isEditMode = mode === "edit";
+ const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
+
+ const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
+ const { data: acntgTree = [] } = useAcntgSeMngTree();
+ const sharTree =
+ isEditMode && initialData?.fnrsSharList?.length ? initialData.fnrsSharList : masterSharTree;
+ const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]);
+ const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
+
+ const form = useForm({
+ resolver: zodResolver(fnrsCnsltnRegistSchema),
+ mode: "onChange",
+ defaultValues:
+ isEditMode && initialData
+ ? mapDetailToFormData(initialData)
+ : getCreateDefaultValues(userInfo, taskSeComCd),
+ });
+
+ function buildFormPayload(
+ data: FnrsCnsltnRegistFormData,
+ prgrsSttsComCd: "SV" | "ST" | "TE",
+ ): FnrsCnsltnApplySaveRequestDto | undefined {
+ const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
+ const validationMessage = validateBizCstSharBalance(
+ data.bizCstList,
+ withTotals,
+ sharItems,
+ costItems,
+ );
+ if (validationMessage) {
+ alert(validationMessage);
+ return undefined;
+ }
+
+ const { taskSeComCd: formTaskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
+
+ return {
+ fnrsCnsltnMng: {
+ ...fnrsCnsltnMngRest,
+ taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
+ fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
+ prgrsSttsComCd,
+ },
+ bzScaleList: data.bzScaleList,
+ bizCstList: data.bizCstList,
+ fnrsSharList: toFnrsSharSaveList(withTotals),
+ cklstResultList: data.cklstResultList.map((item) => ({
+ ...item,
+ selected: item.selected ?? undefined,
+ })),
+ esntlAtchFileList: data.esntlAtchFileList,
+ splmntDmndList: data.splmntDmndList.map(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ ({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
+ ...item,
+ ...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
+ }),
+ ),
+ };
+ }
+
+ async function submitForm(
+ data: FnrsCnsltnRegistFormData,
+ prgrsSttsComCd: "SV" | "ST",
+ ): Promise {
+ const payload = buildFormPayload(data, prgrsSttsComCd);
+ if (!payload) return undefined;
+ return fnrsCnsltnMngService.saveApply(payload);
+ }
+
+ async function submitSupplementForm(data: FnrsCnsltnRegistFormData): Promise {
+ const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
+ if (!docNo) return undefined;
+
+ const payload = buildFormPayload(data, "TE");
+ if (!payload) return undefined;
+
+ return fnrsCnsltnMngService.submitSupplement(docNo, payload);
+ }
+
+ async function onSplmntCmptn(data: FnrsCnsltnRegistFormData) {
+ if (isSubmitting) return;
+
+ const hasEmptySplmntCn = data.splmntDmndList?.some(
+ (item) => !item.splmntCn || !item.splmntCn.trim(),
+ );
+ if (hasEmptySplmntCn) {
+ alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
+ return;
+ }
+
+ const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
+ if (!docNo) {
+ alert("문서번호가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSubmitting(true);
+ const result = await submitSupplementForm(data);
+ if (!result) return;
+
+ alert("보완완료 처리되었습니다.");
+ router.push(`/fnrs-cnsltn/${docNo}`);
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ queryClient.invalidateQueries({
+ queryKey: [...fnrsCnsltnMngBaseKey, "findById", docNo],
+ });
+ } catch (error) {
+ console.error("보완완료 실패:", error);
+ alert("보완완료 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function onSave(data: FnrsCnsltnRegistFormData) {
+ if (isSubmitting) return;
+
+ try {
+ setIsSubmitting(true);
+ const fnrsCnsltnDocNo = await submitForm(data, "SV");
+ if (!fnrsCnsltnDocNo) return;
+
+ alert("저장되었습니다.");
+ router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
+ if (isEditMode && id) {
+ queryClient.invalidateQueries({
+ queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
+ });
+ }
+ } catch (error) {
+ console.error("저장 실패:", error);
+ alert("저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function onApply(data: FnrsCnsltnRegistFormData) {
+ if (isSubmitting) return;
+
+ if (!isFormChecklistComplete(data.cklstResultList)) {
+ alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
+ return;
+ }
+
+ try {
+ setIsSubmitting(true);
+ const fnrsCnsltnDocNo = await submitForm(data, "ST");
+ if (!fnrsCnsltnDocNo) return;
+
+ alert("신청 완료되었습니다.");
+ router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ if (id) {
+ queryClient.invalidateQueries({
+ queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
+ });
+ }
+ } catch (error) {
+ console.error("신청 실패:", error);
+ alert("신청 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ function handleApplyClick() {
+ form.handleSubmit(onApply)();
+ }
+
+ async function handleDelClick() {
+ if (isSubmitting) return;
+
+ const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
+ if (!docNo) {
+ alert("문서번호가 없습니다.");
+ return;
+ }
+
+ if (!(await confirm("삭제하시겠습니까?"))) return;
+
+ try {
+ setIsSubmitting(true);
+ await fnrsCnsltnMngService.remove(docNo);
+ alert("삭제되었습니다.");
+ router.push("/fnrs-cnsltn");
+ } catch (error) {
+ console.error("삭제 실패:", error);
+ alert("삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { FnrsFormPage };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx
new file mode 100644
index 000000000..0e52eba19
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useState } from "react";
+
+import type { PaginationState } from "@tanstack/react-table";
+
+import {
+ type CmCdAdstDstrComboResponseDto,
+ useFindGugunCombo,
+ useFindSidoCombo,
+} from "@/entities/adstdstr";
+import { CodeSelect } from "@/entities/code";
+import type { FnrsCnsltnMngSearchDto } from "@/entities/fnrs-cnsltn";
+import {
+ Button,
+ Input,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@common/components";
+
+import { FnrsList } from "./fnrs-list";
+
+const DEFAULT_PAGE_INDEX = 0;
+const DEFAULT_PAGE_SIZE = 10;
+const DEFAULT_SORT = "fnrsCnsltnDocNo,desc";
+const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
+
+const DEFAULT_SEARCH_PARAMS: FnrsCnsltnMngSearchDto = {
+ bizNm: "",
+ taskSeComCd: "",
+ admdstCd: "",
+ page: DEFAULT_PAGE_INDEX,
+ size: DEFAULT_PAGE_SIZE,
+ sort: DEFAULT_SORT,
+};
+
+const ALL_SELECT_VALUE = "__ALL__";
+
+function FnrsListPage() {
+ const [bizNm, setBizNm] = useState("");
+ const [taskSeComCd, setTaskSeComCd] = useState("");
+ const [sidoComCd, setSidoComCd] = useState("");
+ const [sggComCd, setSggComCd] = useState("");
+ const [pagination, setPagination] = useState({
+ pageIndex: DEFAULT_PAGE_INDEX,
+ pageSize: DEFAULT_PAGE_SIZE,
+ });
+ const [searchParams, setSearchParams] = useState(DEFAULT_SEARCH_PARAMS);
+
+ const { data: sidoData } = useFindSidoCombo();
+ const { data: gugunData } = useFindGugunCombo(sidoComCd);
+ const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
+ const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
+
+ const handleSearch = (event: React.FormEvent) => {
+ event.preventDefault();
+ setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
+ setSearchParams({
+ ...DEFAULT_SEARCH_PARAMS,
+ bizNm,
+ taskSeComCd,
+ admdstCd: sggComCd || sidoComCd || "",
+ page: DEFAULT_PAGE_INDEX,
+ size: pagination.pageSize,
+ });
+ };
+
+ return (
+
+ );
+}
+
+export { FnrsListPage };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx
new file mode 100644
index 000000000..dd9c2292e
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
+
+import { useDataTable, useUserStore } from "@/shared/lib";
+import {
+ FnrsCnsltnMngResponseDto,
+ type FnrsCnsltnMngSearchDto,
+ useFnrsCnsltnMngPage,
+} from "@/entities/fnrs-cnsltn";
+import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
+import { Button } from "@common/components";
+
+import { formatTaskSeComCdForApi } from "../lib/fnrs-cnsltn-regist-schema";
+import { createFnrsCnsltnColumns } from "../model/columns";
+
+interface FnrsListProps {
+ searchParams: FnrsCnsltnMngSearchDto;
+ pagination: PaginationState;
+ onPaginationChange: (pagination: PaginationState) => void;
+}
+
+function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProps) {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const router = useRouter();
+ const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false);
+
+ const queryParams = useMemo(
+ () => ({
+ bizNm: searchParams.bizNm,
+ taskSeComCd: searchParams.taskSeComCd,
+ admdstCd: searchParams.admdstCd,
+ page: pagination.pageIndex,
+ size: pagination.pageSize,
+ sort: searchParams.sort ?? "fnrsCnsltnDocNo,desc",
+ }),
+ [searchParams, pagination],
+ );
+
+ const pageQuery = useFnrsCnsltnMngPage(queryParams);
+ const columns = useMemo(() => createFnrsCnsltnColumns(), []);
+
+ const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
+ const canEdit =
+ (row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
+ row.rgtr === userInfo?.userUnqId;
+
+ router.push(
+ canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`,
+ );
+ };
+
+ const { tableComponent } = useDataTable({
+ query: pageQuery,
+ tableOptions: {
+ columns,
+ state: { pagination },
+ onPaginationChange: (updater) => {
+ const next = typeof updater === "function" ? updater(pagination) : updater;
+ onPaginationChange(next);
+ },
+ getCoreRowModel: getCoreRowModel(),
+ manualPagination: true,
+ },
+ dataTableOptions: {
+ viewPagination: true,
+ onRowClick: handleRowClick,
+ },
+ });
+
+ const handleConfirmTaskSe = (selectedCodes: string[]) => {
+ setIsTaskSeDialogOpen(false);
+ router.push(`/fnrs-cnsltn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`);
+ };
+
+ return (
+ <>
+
+
+
재원협의 목록
+
+
+ {tableComponent}
+
+
+ >
+ );
+}
+
+export { FnrsList };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-regist-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-regist-page.tsx
new file mode 100644
index 000000000..20c5f48b7
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-regist-page.tsx
@@ -0,0 +1,11 @@
+import { FnrsFormPage } from "./fnrs-form-page";
+
+interface FnrsRegistPageProps {
+ taskSeComCd?: string[];
+}
+
+function FnrsRegistPage({ taskSeComCd }: FnrsRegistPageProps) {
+ return ;
+}
+
+export { FnrsRegistPage };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx
new file mode 100644
index 000000000..2423e1ead
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useRef } from "react";
+
+import { useUserStore } from "@/shared/lib";
+import { ElaprDocPrgrsStatus } from "@/entities/elapr";
+import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
+import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
+import { Skeleton } from "@common/components";
+
+import { getEditableReviewStep } from "../lib/review-step-utils";
+import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
+
+import { FloatingActionButtons } from "./components/floating-action-buttons";
+import { AttachmentsSection } from "./sections/attachments-section";
+import { BasicInfoSection } from "./sections/basic-info-section";
+import { BusinessCostSection } from "./sections/business-cost-section";
+import { BusinessScaleSection } from "./sections/business-scale-section";
+import { ChecklistSection } from "./sections/checklist-section";
+import { ContactInfoSection } from "./sections/contact-info-section";
+import { FnrsSharSection } from "./sections/fnrs-shar-section";
+import {
+ TechReviewOpinionSection,
+ type TechReviewOpinionSectionHandle,
+} from "./sections/tech-review-opinion-section";
+
+interface FnrsViewPageProps {
+ id: string;
+}
+
+function FnrsViewPage({ id }: FnrsViewPageProps) {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const techReviewOpinionRef = useRef(null);
+ const { data, isLoading, isError } = useFnrsCnsltnMng(id);
+ const { formState: checklistFormState, setFormState: setChecklistFormState } =
+ useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (isError || !data?.detDto) {
+ return 상세 정보를 찾을 수 없습니다.
;
+ }
+
+ const detDto = data.detDto;
+ const bizSclList = data.bizSclList ?? [];
+ const bizCstList = data.bizCstList ?? [];
+ const cklstResultList = data.cklstResultList ?? [];
+ const esntlFileList = data.esntlFileList ?? [];
+ const etcFileList = data.etcFileList ?? [];
+ const rvwList = data.rvwList ?? [];
+ const splmntDmndList = data.splmntDmndList ?? [];
+ const fnrsSharList = data.fnrsSharList ?? [];
+
+ const editableReviewStep = getEditableReviewStep(
+ userInfo?.dtlInstCd,
+ detDto.prgrsSttsComCd || "",
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export { FnrsViewPage };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/attachments-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/attachments-section.tsx
new file mode 100644
index 000000000..3dbbdf1fd
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/attachments-section.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { formatFileSize } from "@/shared/lib/file-utils";
+import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import type {
+ AtflMngClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+} from "@/entities/fnrs-cnsltn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface AttachmentsSectionProps {
+ esntlFileList: EsntlAtchFileMngTreeResponseDto[];
+ etcFileList: AtflMngClientResponseDto[];
+}
+
+function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export { AttachmentsSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx
new file mode 100644
index 000000000..4b85c4f93
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-form-section.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import { useUserStore } from "@/shared/lib";
+import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
+import { findCodeLabel, useCodeList } from "@/entities/code";
+import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Button,
+ Checkbox,
+ Input,
+ RadioGroup,
+ RadioGroupItem,
+ Table,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import type { FnrsCnsltnRegistFormData } from "../../lib/fnrs-cnsltn-regist-schema";
+
+function BasicInfoFormSection() {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const { control, watch, setValue } = useFormContext();
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
+ const taskSeComCd = watch("fnrsCnsltnMng.taskSeComCd");
+ const { data: taskOptions } = useCodeList("FNRS");
+ const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
+ const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
+
+ function handleBnsSelect(data: BnsApplyData) {
+ setValue("fnrsCnsltnMng.bizNm", data.bizNm);
+ setValue("fnrsCnsltnMng.bizBgngYr", data.bizBgngYr);
+ setValue("fnrsCnsltnMng.bizEndYr", data.bizEndYr);
+ setValue("fnrsCnsltnMng.bizCn", data.bizCn);
+ }
+
+ const taskSeComCdLabel =
+ taskSeComCd
+ .map((code) => findCodeLabel(taskOptions, code))
+ .filter(Boolean)
+ .join(", ") || "-";
+
+ return (
+
+
+
+ setIsPickerOpen(false)}
+ onSelect={handleBnsSelect}
+ />
+
+ );
+}
+
+export { BasicInfoFormSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-section.tsx
new file mode 100644
index 000000000..7015a56d5
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/basic-info-section.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface BasicInfoSectionProps {
+ det: FnrsCnsltnMngResponseDto;
+}
+
+function BasicInfoSection({ det }: BasicInfoSectionProps) {
+ return (
+
+
+
+
+ 사업명
+ {det.bizNm || "-"}
+ 사업기간
+ {(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}
+
+
+ 사업내용
+ {det.bizCn || "-"}
+
+
+ 환경청구분
+ {det.envoSeCdNm || "-"}
+ 구분
+ {det.taskSeCdNm || "-"}
+
+
+ 신청지자체
+
+ {det.sido || "-"} {det.gugun || "-"}
+
+ 확정예산사업번호
+ {det.cfmtnBgtBizNo || "-"}
+
+
+ 예산신청연도
+ {det.bgtAplyYr || "-"}
+
+
+
+
+ );
+}
+
+export { BasicInfoSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-cost-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-cost-section.tsx
new file mode 100644
index 000000000..285fe8bed
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-cost-section.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useMemo } from "react";
+
+import type { BizCstTreeDto } from "@/entities/fnrs-cnsltn";
+import { calcDiffPercent, fmtNumber, indentPx } from "@/widgets/pbadms-sprt";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+import { sortBizCstListByTreePath } from "../../lib/business-cost-utils";
+
+interface BusinessCostSectionProps {
+ bizCstList: BizCstTreeDto[];
+}
+
+function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
+ const sortedBizCstList = useMemo(() => sortBizCstListByTreePath(bizCstList), [bizCstList]);
+
+ return (
+
+
+
(단위: 백만원)
+
+
+
+
+ 구분
+
+ 예산반영(A)
+ 표준사업비(B)
+ 금회설계시(C)
+ 증감
+
+
+ (C-B)
+ %
+
+
+
+ {sortedBizCstList.length === 0 ? (
+
+
+ 조회된 총사업비 항목이 없습니다.
+
+
+ ) : (
+ sortedBizCstList.map((node) => {
+ const pl = indentPx(node.indAcntgSeCdNm);
+ const bscPlanAmt = Number(node.bscPlanAmt) || 0;
+ const budgetReflct = Number(node.bgtRfltAmt) || 0;
+ const tstmDesignAmt = Number(node.tstmDesignAmt) || 0;
+ const diff = tstmDesignAmt - budgetReflct;
+ const showSubIcon = node.treeLevel >= 3;
+
+ return (
+
+ {node.treeLevel <= 1 ? (
+ {node.acntgSeCdNm}
+ ) : (
+
+ {showSubIcon && └}
+ {node.acntgSeCdNm}
+
+ )}
+
+ {fmtNumber(bscPlanAmt)}
+
+
+ {fmtNumber(budgetReflct)}
+
+
+ {fmtNumber(tstmDesignAmt)}
+
+
+
+ {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)}
+
+
+
+ {calcDiffPercent(diff, budgetReflct)}
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
+
+export { BusinessCostSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-scale-section.tsx
new file mode 100644
index 000000000..f8849cf8f
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/business-scale-section.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import type { BizSclTreeDto } from "@/entities/fnrs-cnsltn";
+import { fmtNumber, indentPx } from "@/widgets/pbadms-sprt";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface BusinessScaleSectionProps {
+ bizSclList: BizSclTreeDto[];
+}
+
+function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
+ return (
+
+
+
+
+
+ 구분
+ 기본계획(A)
+ 예산반영(B)
+ 금회설계시(C)
+ 증감(C-B)
+
+
+
+ {bizSclList.length === 0 ? (
+
+
+ 조회된 사업규모 항목이 없습니다.
+
+
+ ) : (
+ bizSclList.map((node) => {
+ const pl = indentPx(node.indCnstpSeCdNm);
+ const bscPlanQty = Number(node.bscPlanQty) || 0;
+ const budgetReflct = Number(node.bgtRfltQty) || 0;
+ const tstmDesignQty = Number(node.tstmDesignQty) || 0;
+ const diff = tstmDesignQty - budgetReflct;
+
+ return (
+
+
+ {node.cnstpSeCdNm}
+
+
+ {fmtNumber(bscPlanQty)}
+
+
+ {fmtNumber(budgetReflct)}
+
+
+ {fmtNumber(tstmDesignQty)}
+
+
+
+ {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
+
+export { BusinessScaleSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/checklist-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/checklist-section.tsx
new file mode 100644
index 000000000..80671876a
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/checklist-section.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
+
+import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
+
+import {
+ computeReviewStepChecklistProgress,
+ extractTaskCodes,
+ getChecklistDisplayRows,
+} from "../../lib/checklist-utils";
+import { getVisibleReviewSteps } from "../../lib/review-step-utils";
+import type { ReviewStepCode } from "../../model/view-types";
+import {
+ type ChecklistFormState,
+ REVIEW_STEP_LABELS,
+ ROLE_TO_REVIEW_STEP,
+ TASK_CODE_LABELS,
+} from "../../model/view-types";
+import { ChecklistTaskPanel } from "../components/checklist-task-panel";
+
+interface ChecklistSectionProps {
+ cklstResultList: CklstRvwRsltTreeDto[];
+ prgrsSttsComCd: string;
+ userRoleCode?: string;
+ formState: ChecklistFormState;
+ setFormState: Dispatch>;
+}
+
+function ChecklistSection({
+ cklstResultList,
+ prgrsSttsComCd,
+ userRoleCode,
+ formState,
+ setFormState,
+}: ChecklistSectionProps) {
+ const visibleReviewSteps = useMemo(
+ () => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
+ [prgrsSttsComCd, userRoleCode],
+ );
+ const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
+
+ const defaultReviewStep = ROLE_TO_REVIEW_STEP[userRoleCode ?? ""] ?? "LOCAL";
+ const [selectedReviewStep, setSelectedReviewStep] = useState(defaultReviewStep);
+ const [selectedTaskCode, setSelectedTaskCode] = useState(null);
+
+ useEffect(() => {
+ if (visibleReviewSteps.length === 0) return;
+ if (!visibleReviewSteps.includes(selectedReviewStep)) {
+ setSelectedReviewStep(visibleReviewSteps[visibleReviewSteps.length - 1]);
+ }
+ }, [selectedReviewStep, visibleReviewSteps]);
+
+ useEffect(() => {
+ if (taskCodes.length === 0) {
+ setSelectedTaskCode(null);
+ return;
+ }
+ if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
+ setSelectedTaskCode(taskCodes[0]);
+ }
+ }, [taskCodes, selectedTaskCode]);
+
+ const progress = useMemo(
+ () =>
+ computeReviewStepChecklistProgress(cklstResultList, formState.results, selectedReviewStep),
+ [cklstResultList, formState.results, selectedReviewStep],
+ );
+
+ const { total, completed, percent } = progress;
+
+ return (
+
+
+ {visibleReviewSteps.length > 0 ? (
+
setSelectedReviewStep(value as ReviewStepCode)}
+ className="w-full"
+ >
+
+ {visibleReviewSteps.map((stepCode) => (
+
+ {REVIEW_STEP_LABELS[stepCode]}
+
+ ))}
+
+
+ ) : null}
+
+ {taskCodes.length > 0 ? (
+
+
+
+ {taskCodes.map((code) => (
+
+ {TASK_CODE_LABELS[code] ?? code}
+
+ ))}
+
+
+ 총합: {total} · 완료: {completed} · 진행률:{" "}
+ {percent}%
+
+
+
+ {taskCodes.map((code) => (
+
+
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+export { ChecklistSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/contact-info-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/contact-info-section.tsx
new file mode 100644
index 000000000..7443460e8
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/contact-info-section.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface ContactInfoSectionProps {
+ det: FnrsCnsltnMngResponseDto;
+}
+
+function ContactInfoSection({ det }: ContactInfoSectionProps) {
+ return (
+
+
+
+ {det.aplcntNm && (
+ <>
+
+ 신청자
+ 이름
+ {det.aplcntNm || "-"}
+ 연락처
+ {det.aplcntMblTelno || "-"}
+
+
+ 소속
+ {det.aplcntOgdpNm || "-"}
+ 이메일
+ {det.aplcntEmlAddr || "-"}
+
+ >
+ )}
+ {det.dsgnrNm && (
+ <>
+
+ 설계자
+ 이름
+ {det.dsgnrNm || "-"}
+ 연락처
+ {det.dsgnrTelno || "-"}
+
+
+ 소속
+ {det.dsgnrOgdpNm || "-"}
+ 이메일
+ {det.dsgnrEmlAddr || "-"}
+
+ >
+ )}
+
+
+
+ );
+}
+
+export { ContactInfoSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx
new file mode 100644
index 000000000..198261c41
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useMemo } from "react";
+
+import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
+import { fmtNumber, indentPx, SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface FnrsSharSectionProps {
+ fnrsSharList: FnrsSharBizCstTreeDto[];
+}
+
+function FnrsSharSection({ fnrsSharList }: FnrsSharSectionProps) {
+ const sortedList = useMemo(
+ () => [...fnrsSharList].sort((a, b) => a.sortPath.localeCompare(b.sortPath)),
+ [fnrsSharList],
+ );
+
+ return (
+
+
+
(단위: 백만원)
+
+
+
+
+ 구분
+
+ 현행(A)
+ 변경요구(B)
+ 증감
+ 비고
+
+
+ (B-A)
+ %
+
+
+
+ {sortedList.length === 0 ? (
+
+
+ 조회된 재원분담 항목이 없습니다.
+
+
+ ) : (
+ sortedList.map((node) => {
+ const pl = indentPx(node.indFnrsSharSeCdNm);
+ const showSubIcon = node.treeLevel >= 2;
+
+ return (
+
+
+ {showSubIcon && └}
+ {node.fnrsSharSeCdNm}
+
+
+ {fmtNumber(node.exstnAmt)}
+
+
+ {fmtNumber(node.chgRqtAmt)}
+
+
+
+ {node.rmrkCn ?? ""}
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
+
+export { FnrsSharSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx
new file mode 100644
index 000000000..dee5f6c8f
--- /dev/null
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
+
+import { PROXY_HOST } from "@/shared/config";
+import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
+import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
+import { attachService } from "@/entities/attach";
+import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Button,
+ RadioGroup,
+ RadioGroupItem,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import type {
+ ReviewOpinionFormData,
+ ReviewOpinionGetFormDataResult,
+} from "../../lib/review-opinion-utils";
+import type { ReviewStepCode } from "../../model/view-types";
+import { REVIEW_STEP_LABELS } from "../../model/view-types";
+
+const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
+
+interface TechReviewOpinionSectionProps {
+ rvwList: FnrsCnsltnRvwOpnnResponseDto[];
+ editableReviewStep?: ReviewStepCode;
+}
+
+export interface TechReviewOpinionSectionHandle {
+ getFormData: () => ReviewOpinionGetFormDataResult | null;
+ resetForm: () => void;
+}
+
+interface TechReviewOpinionTableProps {
+ items: FnrsCnsltnRvwOpnnResponseDto[];
+}
+
+interface UploadedOpinionFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+const EMPTY_FORM_DATA: ReviewOpinionFormData = {
+ rvwRsltCn: "",
+ rvwRsltAnmlyYn: "",
+ atchFileGroupId: "",
+ opnrSeComCd: "",
+};
+
+function createRvwOpnnUploadKey(file: File) {
+ return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
+}
+
+function hasFormContent(formData: ReviewOpinionFormData) {
+ return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
+}
+
+const TechReviewOpinionSection = forwardRef<
+ TechReviewOpinionSectionHandle,
+ TechReviewOpinionSectionProps
+>(function TechReviewOpinionSection({ rvwList, editableReviewStep }, ref) {
+ const [formData, setFormData] = useState({
+ ...EMPTY_FORM_DATA,
+ opnrSeComCd: editableReviewStep ?? "",
+ });
+ const [uploadedFile, setUploadedFile] = useState(null);
+
+ useEffect(() => {
+ setFormData((prev) => ({
+ ...prev,
+ opnrSeComCd: editableReviewStep ?? "",
+ }));
+ }, [editableReviewStep]);
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+
+ setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
+ setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
+ },
+ onError: (_error, _file, fileKey) => {
+ setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
+ },
+ });
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ getFormData: () => {
+ if (!editableReviewStep || !hasFormContent(formData)) {
+ return null;
+ }
+
+ return {
+ formData: {
+ ...formData,
+ opnrSeComCd: editableReviewStep,
+ },
+ rvwList,
+ };
+ },
+ resetForm: () => {
+ setFormData({
+ ...EMPTY_FORM_DATA,
+ opnrSeComCd: editableReviewStep ?? "",
+ });
+ setUploadedFile(null);
+ },
+ }),
+ [editableReviewStep, formData, rvwList],
+ );
+
+ const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
+ const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
+
+ const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
+ const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
+
+ if (!hasEnvSection && !hasKecoSection) {
+ return null;
+ }
+
+ async function handleFileUpload(files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const file = files[0];
+ const fileKey = createRvwOpnnUploadKey(file);
+ const existingAtflCd = formData.atchFileGroupId;
+
+ setUploadedFile({ fileName: file.name, fileKey });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove() {
+ if (!uploadedFile) return;
+
+ uploads[uploadedFile.fileKey]?.abort();
+
+ if (uploadedFile.atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
+ } catch (error) {
+ console.error("검토의견 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ setUploadedFile(null);
+ setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
+ }
+
+ function renderOpinionForm(reviewStep: ReviewStepCode) {
+ if (editableReviewStep !== reviewStep) return null;
+
+ const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
+
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {hasEnvSection ? (
+
+
+ {envRvwList.length > 0 ? : null}
+ {renderOpinionForm("ENV")}
+
+
+ ) : null}
+ {hasKecoSection ? (
+
+
+ {kecoRvwList.length > 0 ? : null}
+ {renderOpinionForm("KECO")}
+
+
+ ) : null}
+ >
+ );
+});
+
+function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ 의견자명
+ 의견내용
+ 검토결과
+ 첨부파일
+ 등록일시
+
+
+
+ {items.map((item) => (
+
+ {item.opnrNm}
+ {item.rvwRsltCn || "-"}
+
+ {item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
+
+
+ {item.atchFileInfo?.atflCd ? (
+
+ {item.atchFileInfo.orgnlFileNm || "다운로드"}
+
+ ) : (
+ "-"
+ )}
+
+
+ {item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
+
+
+ ))}
+
+
+
+ );
+}
+
+export { TechReviewOpinionSection };
diff --git a/apps/as/src/pages/instl-sncn-old/index.ts b/apps/as/src/pages/instl-sncn-old/index.ts
deleted file mode 100644
index 570133ba4..000000000
--- a/apps/as/src/pages/instl-sncn-old/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export { CnfmListPage } from "./ui/cnfm-list-page";
-export { CnfmViewPage } from "./ui/cnfm-view-page";
-export { CnfmRegistPage } from "./ui/cnfm-regist-page";
-export { CnfmModifyPage } from "./ui/cnfm-modify-page";
diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx
deleted file mode 100644
index 2b8c56174..000000000
--- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx
+++ /dev/null
@@ -1,441 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useState } from "react";
-import { useRouter } from "next/navigation";
-
-import { useQuery } from "@tanstack/react-query";
-
-import { dayjs } from "@/shared/lib";
-import { useCodeList } from "@/entities/code";
-import {
- INSTL_SNCN_REGISTER_SELECTION_KEY,
- type InstlSncnMngSearchParams,
- instlSncnService,
-} from "@/entities/instl-sncn";
-
-import "./cnfm-page.css";
-
-const TASK_OPTIONS = [
- { value: "", label: "전체" },
- { value: "SF", label: "하수처리시설" },
- { value: "SP", label: "하수관로" },
- { value: "RE", label: "재이용수" },
-];
-
-const SIDO_OPTIONS = [
- { value: "", label: "전체" },
- { value: "11", label: "서울특별시" },
- { value: "26", label: "부산광역시" },
- { value: "27", label: "대구광역시" },
- { value: "28", label: "인천광역시" },
- { value: "29", label: "광주광역시" },
- { value: "30", label: "대전광역시" },
- { value: "31", label: "울산광역시" },
- { value: "36", label: "세종특별자치시" },
- { value: "41", label: "경기도" },
- { value: "43", label: "충청북도" },
- { value: "44", label: "충청남도" },
- { value: "45", label: "전라북도" },
- { value: "46", label: "전라남도" },
- { value: "47", label: "경상북도" },
- { value: "48", label: "경상남도" },
- { value: "50", label: "제주특별자치도" },
-];
-
-type StepCode = "SV" | "ST" | "ER" | "TE" | "TR" | "TP" | "TA";
-
-const STEPS: { code: StepCode; label: string }[] = [
- { code: "SV", label: "저장" },
- { code: "ST", label: "신청완료" },
- { code: "ER", label: "환경청 검토" },
- { code: "TE", label: "기술검토" },
- { code: "TR", label: "검토결과 회신" },
- { code: "TP", label: "기술평가" },
- { code: "TA", label: "최종승인" },
-];
-
-const PAGE_GROUP_SIZE = 5;
-
-function CnfmListPage() {
- const router = useRouter();
- const [bizNm, setBizNm] = useState("");
- const [taskSeComCd, setTaskSeComCd] = useState("");
- const [sidoComCd, setSidoComCd] = useState("");
- const [sggComCd, setSggComCd] = useState("");
- const [search, setSearch] = useState({
- bizNm: "",
- taskSeComCd: "",
- sidoComCd: "",
- sggComCd: "",
- });
- const [pageIndex, setPageIndex] = useState(0);
- const [pageSize, setPageSize] = useState(10);
- const [taskDialogOpen, setTaskDialogOpen] = useState(false);
- const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState([]);
-
- const { data: fnrsCodeList = [] } = useCodeList("INSTL");
-
- useEffect(() => {
- if (!taskDialogOpen) return;
- if (selectedTaskSeComCd.length) return;
- if (fnrsCodeList.length === 0) return;
- // setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]);
- }, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]);
-
- const query = useQuery({
- queryKey: ["instl-sncn-list", search, pageIndex, pageSize],
- queryFn: () =>
- instlSncnService.getPage({
- ...search,
- page: pageIndex,
- size: pageSize,
- sort: "regDt,desc",
- }),
- });
-
- const totalPages = query.data?.totalPages ?? 0;
-
- const pageGroupStart = Math.floor(pageIndex / PAGE_GROUP_SIZE) * PAGE_GROUP_SIZE;
- const pageNumbers = useMemo(() => {
- const end = Math.min(pageGroupStart + PAGE_GROUP_SIZE, totalPages);
- return Array.from({ length: end - pageGroupStart }, (_, i) => pageGroupStart + i);
- }, [pageGroupStart, totalPages]);
-
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault();
- setPageIndex(0);
- setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd });
- };
-
- const handleOpenTaskDialog = () => {
- //setSelectedTaskSeComCd((prev) => (prev && prev.length ? prev : [fnrsCodeList[0]?.lwpoCd || ""]));
- setTaskDialogOpen(true);
- };
-
- const handleConfirmTaskDialog = () => {
- if (!selectedTaskSeComCd || selectedTaskSeComCd.length === 0) {
- alert("하나 이상의 업무구분을 선택하세요.");
- return;
- }
-
- // prepare arrays of selected codes and names
- const selectedCodes = selectedTaskSeComCd;
- const selectedNames = selectedTaskSeComCd.map(
- (code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "",
- );
- sessionStorage.setItem(
- INSTL_SNCN_REGISTER_SELECTION_KEY,
- JSON.stringify({
- taskSeComCdList: selectedCodes,
- taskSeComCdNmList: selectedNames,
- }),
- );
- setTaskDialogOpen(false);
- router.push("/instl-sncn/register");
- };
-
- return (
-
- {/* 검색 영역 */}
-
-
- {/* 목록 영역 */}
-
-
-
설치인가 목록
-
-
-
-
-
-
-
-
-
-
- | 시도 |
- 시군구 |
- 사업명 |
- 구분 |
- 담당자 |
- 해당기관 |
- 진행상황 |
- 신청일 |
-
-
-
- {query.isLoading && (
-
- |
- 조회 중입니다...
- |
-
- )}
- {!query.isLoading && (query.data?.content?.length ?? 0) === 0 && (
-
- |
- 조회 결과가 없습니다.
- |
-
- )}
- {(query.data?.content ?? []).map((row) => {
- const currentStepIndex = STEPS.findIndex((s) => s.code === row.prgrsSttsComCd);
- return (
- router.push(`/instl-sncn/${row.instlSncnDocNo}`)}
- >
- | {row.sido || "-"} |
- {row.gugun || "-"} |
- {row.bizNm || "-"} |
- {row.taskSeCdNm || row.taskSeComCd || "-"} |
- {row.aplcntNm || "-"} |
- {row.envoSeCdNm || "-"} |
-
-
-
- {STEPS.slice(0, 4).map((step, index) => {
- const state =
- index < currentStepIndex
- ? "completed"
- : index === currentStepIndex
- ? "current"
- : "pending";
- return (
-
-
- {step.label}
-
- );
- })}
-
-
- {STEPS.slice(4).map((step, i) => {
- const index = i + 4;
- const state =
- index < currentStepIndex
- ? "completed"
- : index === currentStepIndex
- ? "current"
- : "pending";
- return (
-
-
- {step.label}
-
- );
- })}
-
-
- |
- {row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"} |
-
- );
- })}
-
-
-
-
- {/* 페이지네이션 */}
-
-
-
-
- {pageNumbers.map((pageNo) => (
-
- ))}
-
-
-
-
-
-
- {taskDialogOpen && (
-
-
-
-
업무구분 선택
-
-
-
-
-
- {fnrsCodeList.map((item) => (
-
- ))}
-
-
-
-
-
-
-
-
-
- )}
-
- );
-}
-
-export { CnfmListPage };
diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx
deleted file mode 100644
index 4be0db4fd..000000000
--- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-
-import { instlSncnService } from "@/entities/instl-sncn";
-
-import { CnfmRegistPage } from "./cnfm-regist-page";
-
-import "./cnfm-page.css";
-
-function CnfmModifyPage({ id }: { id: string }) {
- const query = useQuery({
- // share cache with view page
- queryKey: ["instl-sncn-detail", id],
- queryFn: () => instlSncnService.getById(id),
- });
-
- if (query.isLoading) {
- return 수정 정보를 불러오는 중입니다...
;
- }
-
- if (!query.data) {
- return 수정 대상을 찾을 수 없습니다.
;
- }
-
- const data = query.data;
-
- if (!data) return 수정 대상을 찾을 수 없습니다.
;
-
- return (
-
-
-
- );
-}
-
-export { CnfmModifyPage };
diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css b/apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css
deleted file mode 100644
index 49d7a6bce..000000000
--- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css
+++ /dev/null
@@ -1,490 +0,0 @@
-/* ============================
- 페이지 레이아웃
-============================= */
-.cnfm-page-wrap {
- padding: 2rem 2.4rem;
-}
-
-/* ============================
- 카드
-============================= */
-.cnfm-card {
- border: 1px solid #e2e8f0;
- border-radius: 10px;
- background: #ffffff;
- padding: 1.6rem 2rem;
-}
-
-.cnfm-card.mt-4 {
- margin-top: 1.6rem;
-}
-
-/* ============================
- 검색 바
-============================= */
-.cnfm-search-bar {
- display: flex;
- align-items: center;
- gap: 1.2rem;
- flex-wrap: wrap;
-}
-
-.cnfm-search-field {
- display: flex;
- align-items: center;
- gap: 0.6rem;
-}
-
-.cnfm-search-field--grow {
- flex: 1;
- min-width: 18rem;
-}
-
-.cnfm-search-label {
- font-size: 1.3rem;
- font-weight: 600;
- color: #374151;
- white-space: nowrap;
-}
-
-.cnfm-input {
- height: 3.6rem;
- border: 1px solid #d1d5db;
- border-radius: 6px;
- padding: 0 1rem;
- font-size: 1.3rem;
- color: #111827;
- width: 100%;
- outline: none;
- background: #fff;
-}
-
-.cnfm-input:focus {
- border-color: #3b82f6;
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
-}
-
-.cnfm-select {
- height: 3.6rem;
- border: 1px solid #d1d5db;
- border-radius: 6px;
- padding: 0 0.8rem;
- font-size: 1.3rem;
- color: #111827;
- background: #fff;
- min-width: 11rem;
- cursor: pointer;
- outline: none;
-}
-
-.cnfm-select--wide {
- min-width: 16rem;
-}
-
-.cnfm-select:focus {
- border-color: #3b82f6;
-}
-
-.cnfm-btn-search {
- height: 3.6rem;
- padding: 0 2rem;
- background: #3b82f6;
- color: #fff;
- font-size: 1.3rem;
- font-weight: 600;
- border: none;
- border-radius: 6px;
- cursor: pointer;
- white-space: nowrap;
-}
-
-.cnfm-btn-search:hover {
- background: #2563eb;
-}
-
-/* ============================
- 목록 헤더
-============================= */
-.cnfm-list-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 1.2rem;
-}
-
-.cnfm-list-title {
- font-size: 1.5rem;
- font-weight: 700;
- color: #1f2937;
-}
-
-.cnfm-list-actions {
- display: flex;
- align-items: center;
- gap: 0.8rem;
-}
-
-.cnfm-btn-register {
- height: 3.4rem;
- padding: 0 1.6rem;
- background: #22c55e;
- color: #fff;
- font-size: 1.3rem;
- font-weight: 600;
- border: none;
- border-radius: 6px;
- cursor: pointer;
- white-space: nowrap;
-}
-
-.cnfm-btn-register:hover {
- background: #16a34a;
-}
-
-/* ============================
- 테이블
-============================= */
-.cnfm-table-wrap {
- overflow-x: auto;
-}
-
-.cnfm-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 1.3rem;
-}
-
-.cnfm-table th,
-.cnfm-table td {
- border-bottom: 1px solid #e5e7eb;
- padding: 1.2rem 0.8rem;
- text-align: center;
- vertical-align: middle;
-}
-
-.cnfm-table thead th {
- background: #f8fafc;
- color: #374151;
- font-weight: 600;
- font-size: 1.25rem;
- border-bottom: 2px solid #e2e8f0;
-}
-
-.cnfm-table tbody td {
- color: #111827;
-}
-
-.cnfm-td-empty {
- padding: 3rem 0;
- color: #9ca3af;
- font-size: 1.3rem;
-}
-
-.cnfm-row-clickable {
- cursor: pointer;
-}
-
-.cnfm-row-clickable:hover {
- background: #f8fafc;
-}
-
-/* ============================
- 진행상황 스텝 배지
-============================= */
-.cnfm-step-wrap {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- align-items: flex-start;
-}
-
-.cnfm-step-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- flex-wrap: nowrap;
-}
-
-.cnfm-step-badge {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- border-radius: 9999px;
- padding: 0.3rem 0.9rem;
- font-size: 1.15rem;
- font-weight: 500;
- white-space: nowrap;
- border: 1px solid transparent;
-}
-
-.cnfm-step-badge--completed {
- background: #dbeafe;
- border-color: #93c5fd;
- color: #1d4ed8;
-}
-
-.cnfm-step-badge--current {
- background: #fed7aa;
- border-color: #fb923c;
- color: #c2410c;
- font-weight: 700;
-}
-
-.cnfm-step-badge--pending {
- background: #f3f4f6;
- border-color: #d1d5db;
- color: #9ca3af;
-}
-
-.cnfm-step-dot {
- display: inline-block;
- width: 0.7rem;
- height: 0.7rem;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.cnfm-step-dot--completed {
- background: #3b82f6;
-}
-
-.cnfm-step-dot--current {
- background: #f97316;
-}
-
-.cnfm-step-dot--pending {
- background: #d1d5db;
-}
-
-/* ============================
- 페이지네이션
-============================= */
-.cnfm-pagination {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 0.4rem;
- margin-top: 1.6rem;
- padding-bottom: 0.4rem;
-}
-
-.cnfm-page-btn {
- min-width: 3.2rem;
- height: 3.2rem;
- border: 1px solid #e2e8f0;
- border-radius: 6px;
- background: #fff;
- font-size: 1.3rem;
- color: #374151;
- cursor: pointer;
- transition: all 0.15s;
- padding: 0 0.6rem;
-}
-
-.cnfm-page-btn:hover:not(:disabled) {
- border-color: #3b82f6;
- color: #3b82f6;
-}
-
-.cnfm-page-btn.active {
- border-color: #3b82f6;
- background: #3b82f6;
- color: #fff;
- font-weight: 700;
-}
-
-.cnfm-page-btn:disabled {
- opacity: 0.4;
- cursor: not-allowed;
-}
-
-/* ============================
- 폼 테이블 (상세/등록 페이지용)
-============================= */
-.cnfm-form-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 1.3rem;
-}
-
-.cnfm-form-table th,
-.cnfm-form-table td {
- border: 1px solid #e5e7eb;
- padding: 0.9rem 1rem;
-}
-
-.cnfm-form-table th {
- width: 16rem;
- background: #f9fafb;
- text-align: left;
- font-weight: 700;
-}
-
-.cnfm-form-table td {
- background: #fff;
-}
-
-.cnfm-action-row {
- display: flex;
- justify-content: center;
- gap: 1rem;
- margin-top: 1.8rem;
-}
-
-/* ============================
- 기존 호환 클래스 유지
-============================= */
-.cnfm-section-title {
- font-size: 1.8rem;
- font-weight: 700;
- color: #1f2937;
-}
-
-@media (max-width: 1100px) {
- .cnfm-search-bar {
- flex-direction: column;
- align-items: stretch;
- }
- .cnfm-search-field {
- flex-direction: column;
- align-items: flex-start;
- }
- .cnfm-search-field--grow {
- width: 100%;
- }
-}
-
-
-.cnfm-section-title {
- font-size: 1.8rem;
- font-weight: 700;
- color: #1f2937;
-}
-
-.cnfm-search-grid {
- display: grid;
- grid-template-columns: 8rem 1fr 6rem 16rem auto auto;
- gap: 1.2rem;
- align-items: center;
-}
-
-.cnfm-table-wrap {
- overflow-x: auto;
-}
-
-.cnfm-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 1.3rem;
-}
-
-.cnfm-table th,
-.cnfm-table td {
- border: 1px solid #e5e7eb;
- padding: 1rem 0.8rem;
- text-align: center;
- vertical-align: middle;
-}
-
-.cnfm-table thead th {
- background: #f3f4f6;
- color: #111827;
- font-weight: 700;
-}
-
-.cnfm-row-clickable {
- cursor: pointer;
-}
-
-.cnfm-row-clickable:hover {
- background: #f9fafb;
-}
-
-.cnfm-step {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- flex-wrap: wrap;
- justify-content: center;
-}
-
-.cnfm-step-badge {
- border-radius: 9999px;
- border: 1px solid #d1d5db;
- background: #f9fafb;
- color: #6b7280;
- font-size: 1.1rem;
- padding: 0.25rem 0.65rem;
-}
-
-.cnfm-step-badge.completed {
- border-color: #93c5fd;
- background: #dbeafe;
- color: #1d4ed8;
-}
-
-.cnfm-step-badge.current {
- border-color: #16a34a;
- background: #dcfce7;
- color: #166534;
- font-weight: 700;
-}
-
-.cnfm-pagination {
- display: flex;
- justify-content: center;
- gap: 0.6rem;
- margin-top: 1.2rem;
-}
-
-.cnfm-pagination button {
- min-width: 3.2rem;
- height: 3.2rem;
- border: 1px solid #d1d5db;
- border-radius: 6px;
- background: #fff;
- font-size: 1.3rem;
-}
-
-.cnfm-pagination button.active {
- border-color: #2563eb;
- background: #2563eb;
- color: #fff;
- font-weight: 700;
-}
-
-.cnfm-form-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 1.3rem;
-}
-
-.cnfm-form-table th,
-.cnfm-form-table td {
- border: 1px solid #e5e7eb;
- padding: 0.9rem 1rem;
-}
-
-.cnfm-form-table th {
- width: 16rem;
- background: #f9fafb;
- text-align: left;
- font-weight: 700;
-}
-
-.cnfm-form-table td {
- background: #fff;
-}
-
-.cnfm-action-row {
- display: flex;
- justify-content: center;
- gap: 1rem;
- margin-top: 1.8rem;
-}
-
-@media (max-width: 1100px) {
- .cnfm-search-grid {
- grid-template-columns: 1fr;
- }
-}
diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx
deleted file mode 100644
index 7b545ec06..000000000
--- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx
+++ /dev/null
@@ -1,1945 +0,0 @@
-"use client";
-/* eslint-disable @typescript-eslint/no-explicit-any */
-
-import { useEffect, useMemo, useRef, useState } from "react";
-import { useRouter } from "next/navigation";
-
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import axios from "axios";
-import { type HttpResponse, Upload } from "tus-js-client";
-
-import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config";
-import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib";
-import { formatFileSize } from "@/shared/lib/file-utils";
-import type { AcntgSeMngTreeDto } from "@/entities/acntg";
-import { acntgSeMngService } from "@/entities/acntg";
-import { attachService } from "@/entities/attach";
-import type { CklstTreeNode } from "@/entities/cklst";
-import { cklstMngService } from "@/entities/cklst";
-import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
-import { cnstpSeMngService } from "@/entities/cnstp";
-import { esntlAtchFileMngService, type EsntlAtchFileMngTreeResponseDto } from "@/entities/esntl";
-import {
- INSTL_SNCN_REGISTER_SELECTION_KEY,
- type InstlSncnApplySaveRequestDto,
- type InstlSncnBizCstSaveRequestDto,
- type InstlSncnBzScaleSaveDto,
- type InstlSncnMngResponseDto,
- InstlSncnMngSaveRequestDto,
- instlSncnService,
-} from "@/entities/instl-sncn";
-import { EtcAttachments, NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm";
-import { Button, Input } from "@common/components";
-
-import "./cnfm-page.css";
-
-/* ------------------------------------------------------------------ */
-/* 타입 */
-/* ------------------------------------------------------------------ */
-type BzScaleRow = {
- bscPl: string; // 예산반영(A)
- budgetReflct: string; // 표준사업비(B)
- thtiDsgn: string; // 금회설계시(C)
-};
-
-type BzAmtRow = {
- bscPl: string;
- budgetReflct: string;
- thtiDsgn: string;
-};
-
-type RequiredAttachKey = string;
-
-type RequiredAttachItem = {
- key: RequiredAttachKey;
- esntlAtchFileNm: string;
- esntlAtchYn: "Y" | "N";
- esntlAtchFileRmrk: string;
- atflCd: string;
- atflId: string;
- fileName: string;
- uploading: boolean;
-};
-
-const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
-const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
-
-const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0");
-const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString());
-const DEFAULT_RVW_STP_COM_CD = "LOCAL"; //검토단계공통코드: RVWSTP ( 상세 코드 : LOCAL.지자체,ENV.환경청,KECO.환경공단)
-const DEFAULT_SNCN_SE_COM_CD = "CNFM";
-type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
-const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
-const ROLE_TO_REVIEW_STEP: Record = {
- ROLE_USER05: "LOCAL",
- ROLE_USER04: "LOCAL",
- ROLE_USER03: "ENV",
- ROLE_USER02: "KECO",
- ROLE_USER01: "KECO",
-};
-const REVIEW_STEP_ROLE_RANK: Record = {
- LOCAL: 1,
- ENV: 2,
- KECO: 3,
-};
-const INSTL_PROGRESS_ORDER = [
- "SV",
- "ST",
- "LE",
- "ER",
- "EE",
- "TE",
- "TS",
- "TR",
- "TP",
- "EP",
- "TA",
- "TB",
-];
-
-const getProgressStepCount = (prgrsSttsComCd?: string): number => {
- const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd ?? "ST");
- if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1;
- if (idx <= INSTL_PROGRESS_ORDER.indexOf("ER")) return 2;
- return 3;
-};
-
-const getRoleStepCount = (dtlInstCd?: string): number => {
- const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined;
- return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
-};
-
-// 업무구분 코드 -> 표시명 매핑 (체크리스트 그룹 표시용)
-const TASK_CODE_LABELS: Record = {
- RE: "재이용수",
- SP: "하수관로",
- SF: "하수처리시설",
-};
-
-const normalizeChecklistResultRows = (payload: any): any[] => {
- // expect flat `cklstResultList` only
- const raw = payload?.cklstResultList ?? [];
- return Array.isArray(raw) ? raw : [];
-};
-
-/* ------------------------------------------------------------------ */
-/* 메인 컴포넌트 */
-/* ------------------------------------------------------------------ */
-/**
- * initialData 매핑 안내
- * - initialData 데이터는 cnfm-modify-page에서 서버로부터 받아온 상세 데이터를 그대로 전달받거나, cnfm-regist-page에서 수정 모드로 진입할 때 전달받을 수 있습니다.
- * - 이 컴포넌트는 수정 모드로 호출될 때 `initialData`를 받습니다.
- * - 서버 응답은 두 가지 형태 중 하나일 수 있습니다:
- * 1) 상세 객체 그대로: { ...detFields }
- * 2) 래퍼 형태: { detDto: { ...detFields }, bizSclList: [...], bizCstList: [...], cklstResultList: [...], esntlFileList: [...], etcFileList: [...] }
- *
- * - 주요 detFields (det 또는 detDto 내부):
- * - bizNm, bizBgngYr, bizEndYr, bizCn
- * - sncnSeComCd or envoSeComCd or envoSeCd (설치구분)
- * - dsgnrNm, dsgnrOgdpNm (소속), dsgnrTelno, dsgnrEmlAddr
- * - privacyAgree ("Y" | "N")
- * - aplcntNm, aplcntMblTelno, aplcntOgdpNm, aplcntEmlAddr
- *
- * - 주요 배열 (위치: 최상위 내부)
- * - bizSclList: [{ cnstpSeCd, bscPl|bscPlanQty, budgetReflct|bgtRfltQty, thtiDsgn|tstmDesignQty }, ...]
- * - bizCstList: [{ acntgSeCd, bscPlanAmt|bscPl, bgtRfltAmt|budgetReflct, tstmDesignAmt|thtiDsgn }, ...]
- * - cklstResultList: [{ cklstSn, cklstArtclSn, cklstRvwSn, rvwStpComCd, rvwRsltAnmlyYn: 'Y'|'N'|null, ... }, ...]
- * - esntlFileList: [{ esntlAtchFileId, esntlAtchFileNm, esntlAtchYn, atflCd, atflId, orgnlFileNm, ... }, ...]
- * - etcFileList: [{ atflId, orgnlFileNm, atflFlieSz, upldUserNm, atflCd, ... }, ...]
- *
- * - 주의사항:
- * - 필드명이 서버의 버전이나 API 경로에 따라 달라질 수 있어(예: bscPl vs bscPlanQty) 코드에서 양쪽을 모두 확인합니다.
- * - 일부 배열/속성은 최상위에 있거나 detDto 내부에 있을 수 있으므로 둘 다 우선순위로 체크합니다.
- */
-function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
- const router = useRouter();
- const userInfo = useUserStore((state) => state.userInfo);
- // userInfo?.dtlInstCd 테스트 코드 ENV.ROLE_USER03 , KECO 권한 테스트용 (실제 권한에 맞게 변경 필요)
- /*
- const userInfo = {
- ...userInfo2,
- dtlInstCd: "ROLE_USER03", //ROLE_USER03, ROLE_USER02, ROLE_USER01 KECO 권한 테스트용 (실제 권한에 맞게 변경 필요)
- };
- */
- const alert = useAlertStore((state) => state.setMessage);
- const qc = useQueryClient();
- const currentDet = useMemo(() => initialData?.detDto ?? initialData, [initialData]);
- const isModifyMode = Boolean(currentDet?.instlSncnDocNo);
- const currentPrgrsSttsComCd = (currentDet?.prgrsSttsComCd as string | undefined) ?? "ST";
- const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd);
- const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd);
- const visibleStepCount = isModifyMode
- ? Math.min(progressBasedMaxStepCount, roleBasedMaxStepCount)
- : 1;
- const visibleReviewStepCodes = useMemo(
- () => REVIEW_STEP_CODES.slice(0, visibleStepCount),
- [visibleStepCount],
- );
- const editableReviewStep = useMemo(() => {
- if (!isModifyMode) return "LOCAL" as ReviewStepCode;
- const step = userInfo?.dtlInstCd ? ROLE_TO_REVIEW_STEP[userInfo.dtlInstCd] : undefined;
- if (!step) return null;
- return visibleReviewStepCodes.includes(step) ? step : null;
- }, [isModifyMode, userInfo?.dtlInstCd, visibleReviewStepCodes]);
- const [selectedReviewStep, setSelectedReviewStep] = useState("LOCAL");
-
- useEffect(() => {
- if (visibleReviewStepCodes.length === 0) return;
- // Only set default/initial selection when visible steps or editable step change.
- // Do NOT include `selectedReviewStep` in deps to avoid overwriting user clicks.
- setSelectedReviewStep((current) => {
- // keep current if still visible
- if (current && visibleReviewStepCodes.includes(current)) return current;
- // prefer editable step if available
- if (editableReviewStep) return editableReviewStep;
- // otherwise pick the last visible step
- return visibleReviewStepCodes[visibleReviewStepCodes.length - 1];
- });
- }, [editableReviewStep, visibleReviewStepCodes]);
-
- const docId = useMemo(() => {
- return currentDet?.instlSncnDocNo ?? "";
- }, [currentDet]);
-
- const [docSeComCd] = useState("INSTL");
- const [taskSeComCdNm, setTaskSeComCdNm] = useState(
- () => (currentDet?.taskSeComCdNm as string) ?? "",
- );
- const [taskSeComCd, setTaskSeComCd] = useState(
- () => (currentDet?.taskSeComCd as string) ?? "RE",
- );
-
- useEffect(() => {
- // do not override server-provided initialData (modify mode)
- if (currentDet) return;
-
- const raw = sessionStorage.getItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
- if (!raw) return;
-
- try {
- const parsed = JSON.parse(raw) as {
- taskSeComCdList?: string[];
- taskSeComCdNmList?: string[];
- };
-
- setTaskSeComCd(parsed.taskSeComCdList?.join(",") || "RE");
- setTaskSeComCdNm(parsed.taskSeComCdNmList?.join(",") || "재이용수");
- } catch {
- // noop
- } finally {
- sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
- }
- }, [currentDet]);
- /* ── 기본 정보 ── */
- const [bzNm, setBzNm] = useState("");
- const [bzBgngYr, setBzBgngYr] = useState("");
- const [bzEndYr, setBzEndYr] = useState("");
- const [bzCn, setBzCn] = useState("");
- const [sncnSeComCd, setSncnSeComCd] = useState("CNFM");
-
- /* ── 설계자 정보 ── */
- const [dsgnrNm, setDsgnrNm] = useState("");
- const [dsgnrPsitn, setDsgnrPsitn] = useState("");
- const [dsgnrTelno, setDsgnrTelno] = useState("");
- const [dsgnrEmlAddr, setDsgnrEmlAddr] = useState("");
-
- /* ── 신청자 정보 (수정 모드 매핑용) ── */
- const [aplcntNm, setAplcntNm] = useState("");
- const [aplcntMblTelno, setAplcntMblTelno] = useState("");
- const [aplcntOgdpNm, setAplcntOgdpNm] = useState("");
- const [aplcntEmlAddr, setAplcntEmlAddr] = useState("");
-
- /* ── 개인정보 동의 ── */
- const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N");
-
- const { data: requiredAttachmentDefs = [] } = useQuery({
- queryKey: ["esntlAtchFileTree", docSeComCd],
- queryFn: () => esntlAtchFileMngService.findTree({ docSeComCd }),
- enabled: Boolean(docSeComCd),
- staleTime: Infinity,
- });
-
- // selected task code for header filtering (null = none selected)
- const [selectedTaskCode, setSelectedTaskCode] = useState(null);
-
- const [requiredFiles, setRequiredFiles] = useState>(
- {},
- );
- const requiredFileInputRefs = useRef>({});
-
- useEffect(() => {
- if (requiredAttachmentDefs.length === 0) return;
-
- setRequiredFiles((prev) => {
- const next: Record = {};
- for (const item of requiredAttachmentDefs) {
- const key = item.esntlAtchFileId;
- const prevItem = prev[key];
- next[key] = {
- key,
- esntlAtchFileNm: item.esntlAtchFileNm,
- esntlAtchYn: item.esntlAtchYn,
- esntlAtchFileRmrk: item.esntlAtchFileRmrk,
- atflCd: prevItem?.atflCd ?? "",
- atflId: prevItem?.atflId ?? "",
- fileName: prevItem?.fileName ?? "",
- uploading: prevItem?.uploading ?? false,
- };
- }
- return next;
- });
- }, [requiredAttachmentDefs]);
-
- const uploadRequiredFile = (file: File, key: RequiredAttachKey) => {
- setRequiredFiles((prev) => ({
- ...prev,
- [key]: { ...prev[key], uploading: true },
- }));
-
- setCookie("atfl-cd-key", "");
-
- const upload = new Upload(file, {
- endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
- chunkSize: 10 * 1024 * 1024,
- headers: {
- [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
- [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
- },
- metadata: {
- filename: file.name,
- filetype: file.type,
- },
- onError(error: Error) {
- console.error("필수 첨부파일 업로드 실패", error);
- setRequiredFiles((prev) => ({
- ...prev,
- [key]: { ...prev[key], uploading: false },
- }));
- alert("필수 첨부파일 업로드 중 오류가 발생했습니다.");
- },
- onAfterResponse(_req: unknown, res: HttpResponse) {
- const fileInfo = res.getHeader("X-File-Info");
- if (typeof fileInfo !== "string") return;
-
- const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
- const atflCd = parsed.atflCd ?? "";
- const atflId = parsed.atflId ?? "";
- if (atflCd) setCookie("atfl-cd-key", atflCd);
-
- setRequiredFiles((prev) => ({
- ...prev,
- [key]: {
- ...prev[key],
- atflCd,
- atflId,
- fileName: file.name,
- uploading: false,
- },
- }));
- },
- onSuccess() {
- setRequiredFiles((prev) => ({
- ...prev,
- [key]: { ...prev[key], uploading: false },
- }));
- },
- });
-
- upload.start();
- };
-
- /* ── 기타 첨부파일 ── */
- type EtcFile = {
- id: string;
- name: string;
- ext: string;
- size: string;
- uploader: string;
- atflCd: string;
- atflId: string;
- };
- const [etcFiles, setEtcFiles] = useState([]);
- const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
- const [etcFileGroupId, setEtcFileGroupId] = useState("");
- const [etcUploading, setEtcUploading] = useState(false);
- const etcFileGroupIdRef = useRef("");
- const initialMappedRef = useRef(false);
-
- // 초기 데이터가 주어지면 폼 상태로 매핑 (수정 모드에서 사용)
- useEffect(() => {
- if (!initialData) return;
- if (initialMappedRef.current) return;
-
- try {
- const det = (currentDet ?? {}) as any;
-
- const bizSclList = initialData.bizSclList ?? [];
- const bizCstList = initialData.bizCstList ?? [];
- //const cklstResultList = initialData.cklstResultList ?? [];
- const esntlFileList = initialData.esntlFileList ?? [];
- const etcFileList = initialData.etcFileList ?? [];
-
- // 기본 정보 및 설계자 정보
- setBzNm(det.bizNm ?? "");
- setBzBgngYr(det.bizBgngYr ?? "");
- setBzEndYr(det.bizEndYr ?? "");
- setBzCn(det.bizCn ?? "");
- setSncnSeComCd(det.sncnSeComCd ?? det.envoSeComCd ?? det.envoSeCd ?? DEFAULT_SNCN_SE_COM_CD);
-
- // 설계자
- setDsgnrNm(det.dsgnrNm ?? "");
- setDsgnrPsitn(det.dsgnrOgdpNm ?? "");
- setDsgnrTelno(det.dsgnrTelno ?? "");
- setDsgnrEmlAddr(det.dsgnrEmlAddr ?? "");
-
- // 개인정보 동의
- if (det.privacyAgree) setPrivacyAgree(det.privacyAgree === "Y" ? "Y" : "N");
-
- // 신청자
- setAplcntNm(det.aplcntNm ?? "");
- setAplcntMblTelno(det.aplcntMblTelno ?? "");
- setAplcntOgdpNm(det.aplcntOgdpNm ?? "");
- setAplcntEmlAddr(det.aplcntEmlAddr ?? "");
- // 수정모드 기타첨부파일
- if (Array.isArray(etcFileList)) {
- setEtcFiles(
- etcFileList
- .filter((f: any) => f && typeof f === "object")
- .map((f: any) => {
- const fileName = f.orgnlFileNm ?? f.physFileNm ?? "";
- const ext =
- fileName && typeof fileName === "string" && fileName.includes(".")
- ? (fileName.split(".").pop() ?? "")
- : "";
- return {
- id: f.atflId,
- name: fileName,
- ext,
- size: f.atflFlieSz ?? 0,
- uploader: det.rgtrNm ?? "",
- atflCd: f.atflCd ?? "",
- atflId: f.atflId ?? "",
- };
- }),
- );
- }
-
- // 수정모드시 기타첨부 그룹아이디 적용 (중복 매핑 방지)
- // - det.atchFileGroupId 또는 det.atflCd 우선, 없으면 etcFileList 첫 항목의 atflCd 사용
- const existingGroup =
- det.atchFileGroupId || (Array.isArray(etcFileList) && etcFileList[0]?.atflCd) || "";
- //console.log("etcFileGroupIdRef.current 초기화1:", existingGroup);
- if (existingGroup && etcFileGroupIdRef.current !== existingGroup) {
- etcFileGroupIdRef.current = existingGroup;
- setEtcFileGroupId((prev) => prev || existingGroup);
- }
- // console.log("etcFileGroupIdRef.current 초기화:", etcFileGroupIdRef.current);
-
- // 수정모드인 경우 서버가 전달한 업무구분(단일 혹은 콤마로 결합된 복수)을 상태로 복원
- setTaskSeComCd(det.taskSeComCd);
- setTaskSeComCdNm(det.taskSeCdNm);
-
- // 매핑 완료 플래그 설정 — 이후 동일 컴포넌트 인스턴스에서 재매핑 방지
- initialMappedRef.current = true;
-
- //수정모드 필수첨부파일: requiredFiles는 requiredAttachmentDefs 로 초기화되므로 atflCd/atflId만 덮어쓴다
- if (Array.isArray(esntlFileList)) {
- setRequiredFiles((prev) => {
- const next = { ...prev };
- for (const f of esntlFileList) {
- if (!f || typeof f !== "object") continue;
- const key = f.esntlAtchFileId ?? f.esntlAtchFileId;
- if (!key) continue;
- const existing = next[key] ?? {
- key,
- esntlAtchFileNm: f.esntlAtchFileNm ?? "",
- esntlAtchYn: f.esntlAtchYn ?? "N",
- esntlAtchFileRmrk: "",
- atflCd: "",
- atflId: "",
- fileName: "",
- uploading: false,
- };
-
- next[key] = {
- ...existing,
- atflCd: f.atflCd ?? existing.atflCd,
- atflId: f.atflId ?? existing.atflId,
- fileName: f.orgnlFileNm ?? existing.fileName,
- uploading: false,
- };
- }
- return next;
- });
- }
-
- // 사업규모 매핑
- if (Array.isArray(bizSclList)) {
- const map: Record = {};
- for (const row of bizSclList) {
- if (!row.cnstpSeCd) continue;
- map[row.cnstpSeCd] = {
- bscPl: String(row.bscPl ?? row.bscPlanQty ?? ""),
- budgetReflct: String(row.budgetReflct ?? row.bgtRfltQty ?? ""),
- thtiDsgn: String(row.thtiDsgn ?? row.tstmDesignQty ?? ""),
- };
- }
- setBzScaleMap(map);
- }
-
- // 총사업비 매핑
- if (Array.isArray(bizCstList)) {
- const map: Record = {};
- for (const row of bizCstList) {
- if (!row.acntgSeCd) continue;
- map[row.acntgSeCd] = {
- bscPl: String(row.bscPlanAmt ?? row.bscPl ?? ""),
- budgetReflct: String(row.bgtRfltAmt ?? row.budgetReflct ?? ""),
- thtiDsgn: String(row.tstmDesignAmt ?? row.thtiDsgn ?? ""),
- };
- }
- setAmtMap(map);
- }
- } catch (err) {
- console.error("initialData 매핑 실패", err);
- }
- }, [initialData]);
-
- /** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
- const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
- try {
- if (atflId) {
- await attachService.deleteAttachmentByAtflId(atflId);
- }
- } catch (error) {
- console.error("기타 첨부파일 삭제 실패", error);
- alert("파일 삭제 중 오류가 발생했습니다.");
- return;
- }
-
- setEtcFiles((prev) => {
- const next = prev.filter((x) => x.id !== fileId);
- if (next.length === 0) {
- setEtcFileGroupId("");
- etcFileGroupIdRef.current = "";
- }
- return next;
- });
- };
-
- const handleRequiredFileChange = (
- key: RequiredAttachKey,
- e: React.ChangeEvent,
- ) => {
- const file = e.target.files?.[0];
- if (!file) return;
- uploadRequiredFile(file, key);
- e.target.value = "";
- };
-
- const uploadEtcFile = (file: File): Promise =>
- new Promise((resolve, reject) => {
- let uploadedAtflCd = "";
- let uploadedAtflId = "";
- console.log("기타첨부파일 업로드 시작:", file.name, etcFileGroupIdRef.current);
- const upload = new Upload(file, {
- endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
- chunkSize: 10 * 1024 * 1024,
- headers: {
- [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
- [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
- },
- metadata: {
- filename: file.name,
- filetype: file.type,
- atflCd: etcFileGroupIdRef.current || "",
- },
- onError(error: Error) {
- reject(error);
- },
- onAfterResponse(_req: unknown, res: HttpResponse) {
- const fileInfo = res.getHeader("X-File-Info");
- if (typeof fileInfo !== "string") return;
-
- const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
- uploadedAtflCd = parsed.atflCd ?? "";
- uploadedAtflId = parsed.atflId ?? "";
-
- if (uploadedAtflCd) {
- etcFileGroupIdRef.current = uploadedAtflCd;
- setEtcFileGroupId((prev) => prev || uploadedAtflCd);
- }
- },
- onSuccess() {
- const ext = file.name.includes(".") ? (file.name.split(".").pop() ?? "") : "";
- resolve({
- id: uploadedAtflId || `${Date.now()}_${Math.random()}`,
- name: file.name,
- ext,
- size: formatFileSize(file.size),
- uploader: userInfo?.userName ?? "알 수 없음",
- atflCd: uploadedAtflCd,
- atflId: uploadedAtflId,
- });
- },
- });
-
- upload.start();
- });
-
- const handleEtcFilesChange = async (e: React.ChangeEvent) => {
- const files = Array.from(e.target.files ?? []);
- if (files.length === 0) return;
-
- setEtcUploading(true);
- const uploadedRows: EtcFile[] = [];
- const failedNames: string[] = [];
-
- for (const file of files) {
- try {
- const uploaded = await uploadEtcFile(file);
- uploadedRows.push(uploaded);
- } catch (error) {
- console.error("기타 첨부파일 업로드 실패", error);
- failedNames.push(file.name);
- }
- }
-
- if (uploadedRows.length > 0) {
- setEtcFiles((prev) => [...prev, ...uploadedRows]);
- if (!etcFileGroupId && uploadedRows[0].atflCd) {
- setEtcFileGroupId(uploadedRows[0].atflCd);
- etcFileGroupIdRef.current = uploadedRows[0].atflCd;
- }
- // 업로드 후 상세 페이지가 최신화되도록 해당 상세 쿼리 무효화
- try {
- if (docId) qc.invalidateQueries({ queryKey: ["instl-sncn-detail", docId] });
- else qc.invalidateQueries({ queryKey: ["instl-sncn-detail"] });
- } catch (_e) {
- // noop: 무효화 실패는 치명적이지 않음
- }
- }
-
- if (failedNames.length > 0) {
- alert(`일부 파일 업로드에 실패했습니다. (${failedNames.join(", ")})`);
- }
-
- setEtcUploading(false);
- e.target.value = "";
- };
-
- /* ── 체크리스트 ── */
- type CheckResultVal = "Y" | "N" | null;
- const [checkResults, setCheckResults] = useState>({});
- const [checkComments, setCheckComments] = useState>({});
- const cklstRowRefs = useRef>({});
- const [pendingInvalidKey, setPendingInvalidKey] = useState(null);
-
- const { data: cklstTree = [] } = useQuery({
- queryKey: ["cklstTree", docSeComCd, taskSeComCd],
- queryFn: async () => {
- // support comma-separated task codes (e.g. "RE,SP") by splitting
- const raw = taskSeComCd ?? "";
- const codes = raw
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean);
-
- if (codes.length === 0) return [] as CklstTreeNode[];
- if (codes.length === 1) {
- return await cklstMngService.findTree({ docSeComCd, taskSeComCd: codes[0] });
- }
-
- const results = await Promise.all(
- codes.map((code) => cklstMngService.findTree({ docSeComCd, taskSeComCd: code })),
- );
- const flat = results.flat();
- // dedupe by cklstRvwSn, keep first occurrence
- const map = new Map();
- for (const row of flat) {
- if (!map.has(row.cklstRvwSn)) map.set(row.cklstRvwSn, row);
- }
- return Array.from(map.values());
- },
- enabled: Boolean(docSeComCd) && Boolean(taskSeComCd),
- staleTime: Infinity,
- });
-
- const cklstRows = useMemo(() => {
- // bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함
- // cklstTree 내의 피드 taskSeComCd 에 따라서 그룹핑 되도록 해줘
-
- const useRows = selectedTaskCode
- ? cklstTree.filter((r) => (r as any).taskSeComCd === selectedTaskCode)
- : cklstTree;
- if (useRows.length === 0) return [] as CklstTreeNode[];
-
- // artcl 단위로 분리하지 않고 전체 rows에서 단일 childMap을 구성한다.
- // upCklstRvwSn이 다른 artcl 소속 노드를 부모로 가질 수 있으므로
- // artcl 경계를 넘는 부모-자식 관계도 처리해야 한다.
- const childMap = new Map();
- for (const row of useRows) {
- const parent = row.upCklstRvwSn ?? null;
- if (!childMap.has(parent)) childMap.set(parent, []);
- childMap.get(parent)!.push(row);
- }
-
- for (const list of childMap.values()) {
- list.sort((a, b) =>
- a.cklstArtclSn !== b.cklstArtclSn
- ? a.cklstArtclSn - b.cklstArtclSn
- : a.sortSeq !== b.sortSeq
- ? a.sortSeq - b.sortSeq
- : a.cklstRvwSn - b.cklstRvwSn,
- );
- }
-
- const ordered: CklstTreeNode[] = [];
- const walk = (parent: number | null) => {
- const children = childMap.get(parent) ?? [];
- for (const child of children) {
- ordered.push(child);
- walk(child.cklstRvwSn);
- }
- };
- walk(null);
-
- return ordered;
- }, [cklstTree, selectedTaskCode]);
-
- // when cklstTree changes, default selectedTaskCode to first available code
- useEffect(() => {
- const codes = Array.from(
- new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)),
- );
-
- if (codes.length === 0) {
- if (selectedTaskCode !== null) setSelectedTaskCode(null);
- return;
- }
-
- // if there's no selection yet, or the current selection is not in the new codes,
- // pick the first available code
- if (!selectedTaskCode || !codes.includes(selectedTaskCode)) {
- setSelectedTaskCode(codes[0]);
- }
- }, [cklstTree, selectedTaskCode]);
-
- const cklstKey = (row: CklstTreeNode, rvwStpComCd?: string) => {
- const stepCode = rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD;
- const taskCode = (row as any).taskSeComCd ?? "";
- return `${taskCode}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${stepCode}`;
- };
-
- useEffect(() => {
- setCheckResults((prev) => {
- const next: Record = { ...prev };
- let changed = false;
-
- cklstRows.forEach((row) => {
- visibleReviewStepCodes.forEach((stepCode) => {
- const key = cklstKey(row, stepCode);
- if (!Object.prototype.hasOwnProperty.call(next, key)) {
- next[key] = null;
- changed = true;
- }
- });
- });
-
- // 행이 없어졌을 때 stale key만 정리
- // build liveKeys from full cklstTree so inputs for other taskSeComCd are preserved
- const liveKeys = new Set();
- (cklstTree ?? []).forEach((row) => {
- visibleReviewStepCodes.forEach((stepCode) => {
- liveKeys.add(cklstKey(row, stepCode));
- });
- });
-
- Object.keys(next).forEach((key) => {
- const keyStep = key.split("_").pop();
- if (
- keyStep &&
- visibleReviewStepCodes.includes(keyStep as ReviewStepCode) &&
- !liveKeys.has(key)
- ) {
- delete next[key];
- changed = true;
- }
- });
-
- if (!changed) {
- const prevKeys = Object.keys(prev);
- const nextKeys = Object.keys(next);
- if (prevKeys.length !== nextKeys.length) {
- changed = true;
- } else {
- for (const key of nextKeys) {
- if (prev[key] !== next[key]) {
- changed = true;
- break;
- }
- }
- }
- }
-
- return changed ? next : prev;
- });
- }, [cklstTree, visibleReviewStepCodes]);
-
- // when a previously-missing invalid key is set (due to switching task code),
- // try to scroll/focus it after the rows have re-rendered
- useEffect(() => {
- if (!pendingInvalidKey) return;
- const el = cklstRowRefs.current[pendingInvalidKey];
- if (el) {
- el.scrollIntoView({ behavior: "smooth", block: "center" });
- const firstRadio = el.querySelector(
- `input[name="chk_${pendingInvalidKey}"]`,
- );
- firstRadio?.focus();
- setPendingInvalidKey(null);
- }
- // otherwise, wait for cklstRows update (effect will re-run when pendingInvalidKey remains set)
- }, [pendingInvalidKey, cklstRows]);
-
- // 수정 모드에서 서버의 체크리스트 결과값을 폼 상태로 반영
- useEffect(() => {
- if (!initialData || cklstRows.length === 0) return;
- const results = normalizeChecklistResultRows(initialData);
- if (!Array.isArray(results) || results.length === 0) return;
-
- const lookup = new Map();
- const commentLookup = new Map();
- const stepCodesInResult = new Set();
- for (const r of results) {
- const stepCode = (r.rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD) as ReviewStepCode;
- if (!REVIEW_STEP_CODES.includes(stepCode)) continue;
- stepCodesInResult.add(stepCode);
- const key = `${r.taskSeComCd ?? ""}_${r.cklstSn}_${r.cklstArtclSn ?? ""}_${r.cklstRvwSn}_${stepCode}`;
- if (r.rvwRsltAnmlyYn === "Y" || r.rvwRsltAnmlyYn === "N") lookup.set(key, r.rvwRsltAnmlyYn);
- if (r.picRvwCn) commentLookup.set(key, String(r.picRvwCn));
- }
-
- setCheckResults((prev) => {
- const next = { ...prev };
- let changed = false;
- const targetStepCodes = Array.from(
- new Set([...visibleReviewStepCodes, ...stepCodesInResult]),
- );
-
- for (const row of cklstRows) {
- targetStepCodes.forEach((stepCode) => {
- const k = cklstKey(row, stepCode);
- const v = lookup.get(k);
- // only apply server-provided value when we don't already have a user-entered value
- if (v && (next[k] === null || next[k] === undefined)) {
- next[k] = v as CheckResultVal;
- changed = true;
- }
- });
- }
- return changed ? next : prev;
- });
- // set comment state separately to avoid rerendering conflicts
- setCheckComments((prev) => {
- const next: Record = { ...prev };
- let changed = false;
- for (const [k, v] of commentLookup.entries()) {
- // only set comment if none exists yet (preserve user edits)
- if (!next[k]) {
- next[k] = v;
- changed = true;
- }
- }
- return changed ? next : prev;
- });
- }, [initialData, cklstRows, visibleReviewStepCodes]);
-
- const cklstStats = useMemo(() => {
- if (!selectedReviewStep) {
- return { total: 0, completed: 0, percent: 0 };
- }
- // only include rows that are marked for checkbox use (bchckUseYn === "Y")
- const relevantRows = cklstRows.filter((row) => row.bchckUseYn === "Y");
- const total = relevantRows.length;
- const completed = relevantRows.reduce((acc, row) => {
- const val = checkResults[cklstKey(row, selectedReviewStep)] ?? "";
- return acc + (val ? 1 : 0);
- }, 0);
- const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
- return { total, completed, percent };
- }, [cklstRows, checkResults, selectedReviewStep]);
-
- const toCklstSaveDtoList = () => {
- const persistedStepCodeSet = new Set();
- normalizeChecklistResultRows(initialData).forEach((r: any) => {
- const code = r?.rvwStpComCd as ReviewStepCode | undefined;
- if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code);
- });
- // Always persist currently visible review steps as well
- visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code));
-
- // only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
- // only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
- // Use full cklstTree so items not currently visible (other task codes) are included
- const relevantRows = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y");
-
- // Ensure we generate DTOs grouped by all known task codes (TASK_CODE_LABELS keys)
- const allTaskCodes = Object.keys(TASK_CODE_LABELS);
-
- return allTaskCodes.flatMap((taskCode) =>
- Array.from(persistedStepCodeSet).flatMap((rvwStpComCd) =>
- relevantRows
- .filter((row) => ((row as any).taskSeComCd ?? "") === taskCode)
- .map((row) => ({
- taskSeComCd: taskCode,
- cklstSn: row.cklstSn,
- cklstArtclSn: row.cklstArtclSn,
- cklstRvwSn: row.cklstRvwSn,
- rvwStpComCd: rvwStpComCd,
- selected: checkResults[cklstKey(row, rvwStpComCd)] ?? null,
- picRvwCn: checkComments[cklstKey(row, rvwStpComCd)] ?? null,
- })),
- ),
- );
- };
-
- // removed map-shaped payload generation — server now expects `cklstResultList` only
-
- const toEsntlAtchFileSaveDtoList = () =>
- Object.values(requiredFiles)
- .filter((item) => Boolean(item.atflCd) && Boolean(item.atflId))
- .map((item) => ({
- esntlAtchFileId: item.key,
- atchFileGroupId: item.atflCd,
- }));
-
- /* ── 사업규모 (공종구분 트리 연동) ── */
- // cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
- const [bzScaleMap, setBzScaleMap] = useState>({});
-
- /* ── 공종구분 트리 로드 ── */
- const { data: cnstpTree = [] } = useQuery({
- queryKey: ["cnstpTree"],
- queryFn: () => cnstpSeMngService.findTree(),
- staleTime: Infinity,
- });
-
- /* ── 공종구분 리프 노드 집합 ── */
- const cnstpLeafSet = useMemo(() => {
- const parentCodes = new Set(cnstpTree.map((n) => n.upCnstpCd).filter(Boolean) as string[]);
- return new Set(cnstpTree.filter((n) => !parentCodes.has(n.cnstpSeCd)).map((n) => n.cnstpSeCd));
- }, [cnstpTree]);
-
- const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
- cnstpTree
- .filter(
- (n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd),
- )
- .reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0);
-
- const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
- cnstpLeafSet.has(node.cnstpSeCd)
- ? num((bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col])
- : subtotalScale(node, col);
-
- const handleScaleInput = (cnstpSeCd: string, col: keyof BzScaleRow, val: string) =>
- setBzScaleMap((prev) => ({
- ...prev,
- [cnstpSeCd]: { ...(prev[cnstpSeCd] ?? emptyScale()), [col]: val },
- }));
-
- /** 사업규모 맵 -> 백엔드 저장용 배열 DTO 변환 */
- const toBzScaleSaveDtoList = (): InstlSncnBzScaleSaveDto[] =>
- cnstpTree
- .filter((node) => cnstpLeafSet.has(node.cnstpSeCd))
- .map((node) => {
- const row = bzScaleMap[node.cnstpSeCd] ?? emptyScale();
- const bscPl = num(row.bscPl);
- const budgetReflct = num(row.budgetReflct);
- const thtiDsgn = num(row.thtiDsgn);
-
- return {
- cnstpSeCd: node.cnstpSeCd,
- bscPl,
- budgetReflct,
- thtiDsgn,
- diff: thtiDsgn - budgetReflct,
- };
- });
-
- /* ── 총사업비 (회계구분 트리 연동) ── */
- // acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
- const [amtMap, setAmtMap] = useState>({});
-
- /* ── 회계구분 트리 로드 ── */
- const { data: acntgTree = [] } = useQuery({
- queryKey: ["acntgTree"],
- queryFn: () => acntgSeMngService.findTree(),
- staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요
- });
-
- /* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */
- const leafSet = useMemo(() => {
- const parentCodes = new Set(acntgTree.map((n) => n.upAcntgSeCd).filter(Boolean) as string[]);
- return new Set(acntgTree.filter((n) => !parentCodes.has(n.acntgSeCd)).map((n) => n.acntgSeCd));
- }, [acntgTree]);
-
- /**
- * treePath 기반 소계 계산
- * - 이 노드의 acntgSeCd 가 treePath 안에 포함된 리프 노드들의 합산
- * - 새로운 하위 레벨이 추가돼도 자동으로 포함됨
- */
- const subtotal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number =>
- acntgTree
- .filter((n) => leafSet.has(n.acntgSeCd) && n.treePath.split("|").includes(node.acntgSeCd))
- .reduce((sum, n) => sum + num((amtMap[n.acntgSeCd] ?? emptyAmt())[col]), 0);
-
- /** 노드 값 반환 — 리프이면 입력값, 부모이면 소계 */
- const nodeVal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number =>
- leafSet.has(node.acntgSeCd)
- ? num((amtMap[node.acntgSeCd] ?? emptyAmt())[col])
- : subtotal(node, col);
-
- /** 단일 셀 입력 핸들러 */
- const handleAmtInput = (acntgSeCd: string, col: keyof BzAmtRow, val: string) =>
- setAmtMap((prev) => ({
- ...prev,
- [acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val },
- }));
-
- /** 총사업비 맵 -> 백엔드 저장용 배열 DTO 변환 */
- const toBizCstSaveDtoList = (): InstlSncnBizCstSaveRequestDto[] =>
- acntgTree
- .filter((node) => leafSet.has(node.acntgSeCd))
- .map((node) => {
- const row = amtMap[node.acntgSeCd] ?? emptyAmt();
- return {
- acntgSeCd: node.acntgSeCd,
- bscPlanAmt: num(row.bscPl),
- bgtRfltAmt: num(row.budgetReflct),
- tstmDesignAmt: num(row.thtiDsgn),
- };
- });
-
- /**
- * indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환
- * (공백 4칸 = 1 depth = 1rem)
- */
- const indentPx = (indNm: string): number => {
- const spaces = indNm.length - indNm.trimStart().length;
- return (spaces / 4) * 16; // 4공백 = 16px
- };
-
- /** 트리 행 스타일 분류 */
- const rowStyle = (node: AcntgSeMngTreeDto) => {
- if (node.treeLevel === 1) return "bg-blue-50 font-bold";
- if (node.treeLevel === 2) return "bg-gray-100 font-semibold";
- if (!leafSet.has(node.acntgSeCd)) return "font-semibold"; // 중간 부모 (예: 하수관로)
- return "";
- };
-
- /** 공종 트리 행 스타일 분류 */
- const scaleRowStyle = (node: CnstpSeMngTreeDto) => {
- if (node.treeLevel === 1) return "bg-gray-50 font-semibold";
- if (!cnstpLeafSet.has(node.cnstpSeCd)) return "font-semibold";
- return "";
- };
-
- /* ── 증감 라벨 ── */
- const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v));
-
- /* ── 검증 ── */
- function fnValidation(mode: "SAVE" | "PRESENTN") {
- if (!bzNm.trim()) {
- alert("사업명을 입력하세요.");
- return false;
- }
- if (!bzBgngYr || !bzEndYr) {
- alert("사업기간을 입력하세요.");
- return false;
- }
- if (!bzCn.trim()) {
- alert("사업내용을 입력하세요.");
- return false;
- }
- if (mode === "PRESENTN" && privacyAgree !== "Y") {
- alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
- return false;
- }
-
- if (!editableReviewStep) {
- alert("현재 사용자 권한/진행상태 조합에서 입력 가능한 체크리스트 단계가 없습니다.");
- return false;
- }
-
- // 현재 입력 단계의 체크리스트 미선택 항목이 있으면 진행 불가
- /*
- const hasUncheckedChecklist = cklstRows.some(
- (row) => !checkResults[cklstKey(row, editableReviewStep)],
- );
- if (hasUncheckedChecklist) {
- alert("체크리스트를 모두 체크하세요.");
- return false;
- }
- */
- // 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증
- if (mode === "SAVE") {
- const missingRequiredFile = requiredAttachmentDefs.find((item) => {
- if (item.esntlAtchYn !== "Y") return false;
- const uploaded = requiredFiles[item.esntlAtchFileId];
- return !(uploaded?.atflCd && uploaded?.atflId);
- });
- if (missingRequiredFile) {
- alert(`${missingRequiredFile.esntlAtchFileNm} 파일을 첨부하세요.`);
- return false;
- }
-
- // 저장 시: bchckUseYn =="Y" 인 경우, 화면에 표시되는 모든 review step에 대해
- // 검토 결과가 선택되어 있어야 함 (visibleReviewStepCodes 기준)
- // check across all checklist items (not only currently visible rows)
- const allRelevant = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y");
- const missingRow = allRelevant.find((row) =>
- visibleReviewStepCodes.some((step) => !checkResults[cklstKey(row, step)]),
- );
- if (missingRow) {
- const missingStep = visibleReviewStepCodes.find(
- (step) => !checkResults[cklstKey(missingRow, step)],
- )!;
- const invalidKey = cklstKey(missingRow, missingStep);
- console.warn("검토 결과 미선택 항목 발견:", invalidKey, missingRow, missingStep);
-
- const invalidRowEl = cklstRowRefs.current[invalidKey];
- if (invalidRowEl) {
- invalidRowEl.scrollIntoView({ behavior: "smooth", block: "center" });
- const firstRadio = invalidRowEl.querySelector(
- `input[name="chk_${invalidKey}"]`,
- );
- firstRadio?.focus();
- } else {
- // row not visible: switch header filter to the missing row's task code,
- // then focus after render via pendingInvalidKey
- const taskCode = (missingRow as any).taskSeComCd ?? null;
- if (taskCode) setSelectedTaskCode(taskCode);
- setPendingInvalidKey(invalidKey);
- }
-
- alert(`체크리스트 항목 "${missingRow.rvwCn}"의 검토결과를 선택하세요.`);
- return false;
- }
- }
-
- return true;
- }
-
- const toInstlSncnMngPayload = (
- prgrsSttsComCd: "SV" | "ST" | "ER",
- ): InstlSncnMngSaveRequestDto => ({
- instlSncnDocNo: docId ?? "",
- admdstCd: "",
- envoSeComCd: currentDet?.envoSeComCd ?? "",
- sncnSeComCd: sncnSeComCd || DEFAULT_SNCN_SE_COM_CD,
- taskSeComCd,
- bgtBizCd: "",
- bgtAplyYr: "",
- bizNm: bzNm,
- bizBgngYr: bzBgngYr,
- bizEndYr: bzEndYr,
- bizCn: bzCn,
- aplcntNm: aplcntNm || userInfo?.userName || "",
- aplcntMblTelno: aplcntMblTelno || (userInfo?.mphn ?? userInfo?.telno ?? ""),
- aplcntOgdpNm: aplcntOgdpNm || (userInfo?.instSeNm ?? ""),
- aplcntEmlAddr: aplcntEmlAddr || (userInfo?.email ?? ""),
- dsgnrNm,
- dsgnrTelno,
- dsgnrOgdpNm: dsgnrPsitn,
- dsgnrEmlAddr,
- atchFileGroupId: etcFileGroupId,
- prgrsSttsComCd,
- });
-
- async function handleSave() {
- if (!fnValidation("SAVE")) return;
- // 저장 시에는 `cklstResultList`만 전송합니다 (map 필드 제거)
- const payload: InstlSncnApplySaveRequestDto = {
- instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장)
- bzScaleList: toBzScaleSaveDtoList(),
- bizCstList: toBizCstSaveDtoList(),
- cklstResultList: toCklstSaveDtoList(),
- esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
- };
-
- try {
- const issuedDocNo = await instlSncnService.saveApply(payload);
- alert(`저장되었습니다. 문서번호: ${issuedDocNo}`);
- } catch (error) {
- console.error("저장 API 호출 실패", error);
- const serverMessage =
- axios.isAxiosError(error) && error.response?.data?.message
- ? (error.response.data as { message: string }).message
- : "저장 중 오류가 발생했습니다.";
- alert(serverMessage);
- }
- }
-
- async function handleSubmit() {
- if (!fnValidation("SAVE")) return;
-
- const payload: InstlSncnApplySaveRequestDto = {
- instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장)
- bzScaleList: toBzScaleSaveDtoList(),
- bizCstList: toBizCstSaveDtoList(),
- cklstResultList: toCklstSaveDtoList(),
- esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
- };
-
- try {
- const issuedDocNo = await instlSncnService.saveApply(payload);
- alert(`제출 되었습니다. 문서번호: ${issuedDocNo}`);
- } catch (error) {
- console.error("저장 API 호출 실패", error);
- const serverMessage =
- axios.isAxiosError(error) && error.response?.data?.message
- ? (error.response.data as { message: string }).message
- : "저장 중 오류가 발생했습니다.";
- alert(serverMessage);
- }
- }
-
- /* ---------------------------------------------------------------- */
- return (
-
- {/* ── 프로세스 스텝 바 ── */}
-