From 0533d229e67cad4d4e3973df144c9e81b560270a Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 29 Jun 2026 17:59:18 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20feat:=20=EC=84=A4=EC=B9=98=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=20=EA=B2=80=ED=86=A0=EC=9D=98=EA=B2=AC=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=ED=8F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/lib/checklist-utils.ts | 115 -------- .../instl-sncn/lib/review-opinion-utils.ts | 37 +++ .../pages/instl-sncn/lib/review-step-utils.ts | 43 +++ .../instl-sncn/lib/save-payload-utils.ts | 104 +++++++ .../ui/components/floating-action-buttons.tsx | 49 +++- .../pages/instl-sncn/ui/instl-view-page.tsx | 22 +- .../ui/sections/checklist-section.tsx | 2 +- .../sections/tech-review-opinion-section.tsx | 263 +++++++++++++++++- 8 files changed, 498 insertions(+), 137 deletions(-) create mode 100644 apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts create mode 100644 apps/as/src/pages/instl-sncn/lib/review-step-utils.ts create mode 100644 apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts diff --git a/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts index adeb89308..cb31e7297 100644 --- a/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts +++ b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts @@ -1,15 +1,6 @@ import type { - BizCstTreeDto, - BizSclTreeDto, CklstRvwRsltTreeDto, - EsntlAtchFileMngTreeResponseDto, - InstlSncnApplySaveRequestDto, - InstlSncnBizCstSaveRequestDto, - InstlSncnBzScaleSaveDto, InstlSncnCklstResultSaveDto, - InstlSncnEsntlAtchFileSaveDto, - InstlSncnMngDetResponse, - InstlSncnMngResponseDto, ReviewStepCode, } from "@/entities/instl-sncn"; @@ -18,10 +9,7 @@ import { type ChecklistProgress, type ChecklistRowKey, type ChecklistStepMap, - INSTL_PROGRESS_ORDER, REVIEW_STEP_CODES, - REVIEW_STEP_ROLE_RANK, - ROLE_TO_REVIEW_STEP, } from "../model/view-types"; export function buildChecklistRowKey( @@ -41,26 +29,6 @@ export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap return base; } -export function getProgressStepCount(prgrsSttsComCd?: string): number { - const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]); - if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1; - if (idx <= INSTL_PROGRESS_ORDER.indexOf("LE")) return 2; - return 3; -} - -export function getRoleStepCount(dtlInstCd?: string): number { - const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; - return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; -} - -export function getVisibleReviewSteps( - prgrsSttsComCd: string, - userRoleCode?: string, -): ReviewStepCode[] { - const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode)); - return REVIEW_STEP_CODES.slice(0, count); -} - export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] { return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean))); } @@ -211,86 +179,3 @@ export function toCklstSaveDto( }; }); } - -export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] { - return bizSclList - .filter((node) => node.useYn === "Y") - .map((node) => ({ - cnstpSeCd: node.cnstpSeCd, - bscPl: node.bscPlanQty, - budgetReflct: node.bgtRfltQty, - thtiDsgn: node.tstmDesignQty, - diff: node.tstmDesignQty - node.bgtRfltQty, - })); -} - -export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] { - return bizCstList - .filter((node) => node.useYn === "Y") - .map((node) => ({ - acntgSeCd: node.acntgSeCd, - bscPlanAmt: node.bscPlanAmt, - bgtRfltAmt: node.bgtRfltAmt, - tstmDesignAmt: node.tstmDesignAmt, - })); -} - -export function toEsntlAtchFileSaveDto( - esntlFileList: EsntlAtchFileMngTreeResponseDto[], -): InstlSncnEsntlAtchFileSaveDto[] { - const esntlMap = new Map(); - - for (const item of esntlFileList) { - if (!item.esntlAtchFileId || !item.atflCd) continue; - esntlMap.set(item.esntlAtchFileId, item.atflCd); - } - - return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({ - esntlAtchFileId, - atchFileGroupId, - })); -} - -export function buildSavePayload( - data: InstlSncnMngDetResponse, - checklistFormState: ChecklistFormState, - reviewStep: ReviewStepCode, - prgrsSttsComCd: string, -): InstlSncnApplySaveRequestDto { - const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data; - - return { - instlSncnMng: { ...detDto, prgrsSttsComCd }, - bzScaleList: toBzScaleSaveDto(bizSclList), - bizCstList: toBizCstSaveDto(bizCstList), - cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep), - esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList), - }; -} - -export function buildTechReviewReplyPayload( - data: InstlSncnMngDetResponse, - checklistFormState: ChecklistFormState, - reviewStep: ReviewStepCode, - prgrsSttsComCd: string, - techReViewSaveCd: string, -): InstlSncnApplySaveRequestDto { - return { - ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd), - techReViewSaveCd, - rvwStpCd: reviewStep, - }; -} - -export function buildTechReviewPayload( - det: InstlSncnMngResponseDto, - cklstResultList: InstlSncnCklstResultSaveDto[], -): InstlSncnApplySaveRequestDto { - return { - instlSncnMng: { ...det, prgrsSttsComCd: "ER" }, - bzScaleList: [], - bizCstList: [], - cklstResultList, - esntlAtchFileList: [], - }; -} diff --git a/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts b/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts new file mode 100644 index 000000000..56e34a880 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts @@ -0,0 +1,37 @@ +import type { + InstlSncnMngDetResponse, + InstlSncnRvwOpnnSaveRequestDto, +} from "@/entities/instl-sncn"; + +export interface ReviewOpinionFormData { + rvwRsltCn: string; + rvwRsltAnmlyYn: "Y" | "N" | ""; + atchFileGroupId: string; + opnrSeComCd: string; +} + +export function toRvwOpnnSaveDto( + data: InstlSncnMngDetResponse, + reviewOpinionFormData: ReviewOpinionFormData | null | undefined, + opnrNm?: string, +): InstlSncnRvwOpnnSaveRequestDto[] | undefined { + if (!reviewOpinionFormData) return undefined; + + const hasContent = + reviewOpinionFormData.rvwRsltCn.trim() || + reviewOpinionFormData.rvwRsltAnmlyYn || + reviewOpinionFormData.atchFileGroupId; + + if (!hasContent) return undefined; + + return [ + { + instlSncnDocNo: data.detDto.instlSncnDocNo, + opnrSeComCd: reviewOpinionFormData.opnrSeComCd, + opnrNm: opnrNm ?? "", + rvwRsltCn: reviewOpinionFormData.rvwRsltCn, + rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined, + atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined, + }, + ]; +} diff --git a/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts b/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts new file mode 100644 index 000000000..68f60f4c9 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts @@ -0,0 +1,43 @@ +import type { ReviewStepCode } from "@/entities/instl-sncn"; + +import { + INSTL_PROGRESS_ORDER, + REVIEW_STEP_CODES, + REVIEW_STEP_ROLE_RANK, + ROLE_TO_REVIEW_STEP, +} from "../model/view-types"; + +export function getProgressStepCount(prgrsSttsComCd?: string): number { + const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]); + if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1; + if (idx <= INSTL_PROGRESS_ORDER.indexOf("LE")) return 2; + return 3; +} + +export function getRoleStepCount(dtlInstCd?: string): number { + const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; + return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; +} + +export function getVisibleReviewSteps( + prgrsSttsComCd: string, + userRoleCode?: string, +): ReviewStepCode[] { + const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode)); + return REVIEW_STEP_CODES.slice(0, count); +} + +export function getEditableReviewStep( + dtlInstCd?: string, + prgrsSttsComCd?: string, +): ReviewStepCode | undefined { + if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) { + return "ENV"; + } + + if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") { + return "KECO"; + } + + return undefined; +} diff --git a/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts new file mode 100644 index 000000000..adea42a99 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts @@ -0,0 +1,104 @@ +import type { + BizCstTreeDto, + BizSclTreeDto, + EsntlAtchFileMngTreeResponseDto, + InstlSncnApplySaveRequestDto, + InstlSncnBizCstSaveRequestDto, + InstlSncnBzScaleSaveDto, + InstlSncnCklstResultSaveDto, + InstlSncnEsntlAtchFileSaveDto, + InstlSncnMngDetResponse, + InstlSncnMngResponseDto, + ReviewStepCode, +} from "@/entities/instl-sncn"; + +import type { ChecklistFormState } from "../model/view-types"; + +import { toCklstSaveDto } from "./checklist-utils"; +import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils"; + +export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] { + return bizSclList + .filter((node) => node.useYn === "Y") + .map((node) => ({ + cnstpSeCd: node.cnstpSeCd, + bscPl: node.bscPlanQty, + budgetReflct: node.bgtRfltQty, + thtiDsgn: node.tstmDesignQty, + diff: node.tstmDesignQty - node.bgtRfltQty, + })); +} + +export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] { + return bizCstList + .filter((node) => node.useYn === "Y") + .map((node) => ({ + acntgSeCd: node.acntgSeCd, + bscPlanAmt: node.bscPlanAmt, + bgtRfltAmt: node.bgtRfltAmt, + tstmDesignAmt: node.tstmDesignAmt, + })); +} + +export function toEsntlAtchFileSaveDto( + esntlFileList: EsntlAtchFileMngTreeResponseDto[], +): InstlSncnEsntlAtchFileSaveDto[] { + const esntlMap = new Map(); + + for (const item of esntlFileList) { + if (!item.esntlAtchFileId || !item.atflCd) continue; + esntlMap.set(item.esntlAtchFileId, item.atflCd); + } + + return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({ + esntlAtchFileId, + atchFileGroupId, + })); +} + +export function buildSavePayload( + data: InstlSncnMngDetResponse, + checklistFormState: ChecklistFormState, + reviewStep: ReviewStepCode, + prgrsSttsComCd: string, +): InstlSncnApplySaveRequestDto { + const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data; + + return { + instlSncnMng: { ...detDto, prgrsSttsComCd }, + bzScaleList: toBzScaleSaveDto(bizSclList), + bizCstList: toBizCstSaveDto(bizCstList), + cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep), + esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList), + }; +} + +export function buildTechReviewReplyPayload( + data: InstlSncnMngDetResponse, + checklistFormState: ChecklistFormState, + reviewStep: ReviewStepCode, + prgrsSttsComCd: string, + techReViewSaveCd: string, + reviewOpinionFormData?: ReviewOpinionFormData | null, + opnrNm?: string, +): InstlSncnApplySaveRequestDto { + return { + ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd), + techReViewSaveCd, + rvwStpCd: reviewStep, + rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm), + }; +} + +export function buildTechReviewPayload( + det: InstlSncnMngResponseDto, + cklstResultList: InstlSncnCklstResultSaveDto[], +): InstlSncnApplySaveRequestDto { + return { + instlSncnMng: { ...det, prgrsSttsComCd: "ER" }, + bzScaleList: [], + bizCstList: [], + cklstResultList, + esntlAtchFileList: [], + }; +} diff --git a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx index 1bbafa208..f9bbd2834 100644 --- a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx +++ b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx @@ -25,12 +25,10 @@ import { type InstlSncnViewButtonAction, needsRoleUser01AtrzResolve, } from "../../lib/button-config"; -import { - buildSavePayload, - buildTechReviewReplyPayload, - computeReviewStepChecklistProgress, -} from "../../lib/checklist-utils"; +import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils"; +import { buildSavePayload, buildTechReviewReplyPayload } from "../../lib/save-payload-utils"; import { type ChecklistFormState } from "../../model/view-types"; +import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section"; import { ElaprApprovalDialog } from "./elapr-approval-dialog"; import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog"; @@ -43,6 +41,8 @@ interface FloatingActionButtonsProps { prgrsSttsComCd: string; dtlInstCd: string | undefined; checklistFormState: ChecklistFormState; + techReviewOpinionRef?: React.RefObject; + opnrNm?: string; } type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null; @@ -54,6 +54,8 @@ function FloatingActionButtons({ prgrsSttsComCd, dtlInstCd, checklistFormState, + techReviewOpinionRef, + opnrNm, }: FloatingActionButtonsProps) { const router = useRouter(); const { confirm } = useConfirm(); @@ -251,6 +253,10 @@ function FloatingActionButtons({ }); } + function getReviewOpinionFormData() { + return techReviewOpinionRef?.current?.getFormData() ?? null; + } + async function handleRvwRplyClick() { const { percent } = computeReviewStepChecklistProgress( data.cklstResultList ?? [], @@ -263,13 +269,22 @@ function FloatingActionButtons({ return; } - const payload = buildTechReviewReplyPayload(data, checklistFormState, "ENV", "EP", "CP"); + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "ENV", + "EP", + "CP", + getReviewOpinionFormData(), + opnrNm, + ); await executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, request: payload, successMessage: "검토결과 회신이 완료되었습니다.", }); + techReviewOpinionRef?.current?.resetForm(); } async function handleRqstTechRvwClick() { @@ -303,13 +318,22 @@ function FloatingActionButtons({ } async function handleRvwStrgClick() { - const payload = buildTechReviewReplyPayload(data, checklistFormState, "KECO", "TE", "SV"); + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "KECO", + "TE", + "SV", + getReviewOpinionFormData(), + opnrNm, + ); await executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg, request: payload, successMessage: "저장되었습니다.", }); + techReviewOpinionRef?.current?.resetForm(); } async function handleRvwCmptnClick() { @@ -324,13 +348,22 @@ function FloatingActionButtons({ return; } - const payload = buildTechReviewReplyPayload(data, checklistFormState, "KECO", "TE", "CP"); + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "KECO", + "TE", + "CP", + getReviewOpinionFormData(), + opnrNm, + ); await executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn, request: payload, successMessage: "검토완료 처리되었습니다.", }); + techReviewOpinionRef?.current?.resetForm(); } function handleDmndSplmntClick() { diff --git a/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx index fb1bf5fa1..12ae43453 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx @@ -1,9 +1,12 @@ "use client"; +import { useRef } from "react"; + import { useUserStore } from "@/shared/lib"; import { useInstlSncnDetail } from "@/entities/instl-sncn"; 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"; @@ -13,7 +16,10 @@ 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 { TechReviewOpinionSection } from "./sections/tech-review-opinion-section"; +import { + TechReviewOpinionSection, + type TechReviewOpinionSectionHandle, +} from "./sections/tech-review-opinion-section"; interface InstlViewPageProps { id: string; @@ -21,6 +27,7 @@ interface InstlViewPageProps { function InstlViewPage({ id }: InstlViewPageProps) { const userInfo = useUserStore((state) => state.userInfo); + const techReviewOpinionRef = useRef(null); const { data, isLoading, isError } = useInstlSncnDetail(id); const { formState: checklistFormState, setFormState: setChecklistFormState } = useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST); @@ -58,6 +65,11 @@ function InstlViewPage({ id }: InstlViewPageProps) { rvwList = [], } = data; + const editableReviewStep = getEditableReviewStep( + userInfo?.dtlInstCd, + detDto.prgrsSttsComCd || "", + ); + return (
@@ -72,7 +84,11 @@ function InstlViewPage({ id }: InstlViewPageProps) { setFormState={setChecklistFormState} /> - +
); diff --git a/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx index 2f7e8dd74..b7dab7962 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx @@ -9,8 +9,8 @@ import { computeReviewStepChecklistProgress, extractTaskCodes, getChecklistDisplayRows, - getVisibleReviewSteps, } from "../../lib/checklist-utils"; +import { getVisibleReviewSteps } from "../../lib/review-step-utils"; import { type ChecklistFormState, REVIEW_STEP_LABELS, diff --git a/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx index 69dc7ae59..e7b80386e 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx @@ -1,44 +1,285 @@ "use client"; -import { PROXY_HOST } from "@/shared/config"; -import { dayjs } from "@/shared/lib"; -import type { InstlSncnRvwOpnnResponseDto } from "@/entities/instl-sncn"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; +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 { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn"; +import { + Button, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils"; import { REVIEW_STEP_LABELS } from "../../model/view-types"; import { SectionCollapsible } from "../components/section-collapsible"; +const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; + interface TechReviewOpinionSectionProps { rvwList: InstlSncnRvwOpnnResponseDto[]; + editableReviewStep?: ReviewStepCode; +} + +export interface TechReviewOpinionSectionHandle { + getFormData: () => ReviewOpinionFormData | null; + resetForm: () => void; } interface TechReviewOpinionTableProps { items: InstlSncnRvwOpnnResponseDto[]; } -function TechReviewOpinionSection({ rvwList }: TechReviewOpinionSectionProps) { +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, + opnrSeComCd: editableReviewStep, + }; + }, + resetForm: () => { + setFormData({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + setUploadedFile(null); + }, + }), + [editableReviewStep, formData], + ); + const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); - if (envRvwList.length === 0 && kecoRvwList.length === 0) { + 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 ( +
+

검토의견 작성

+ + + + 의견내용 + +