diff --git a/apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx new file mode 100644 index 000000000..e5df52e22 --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { DesignEditPage } from "@/pages/design-cnstn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx new file mode 100644 index 000000000..1b0fc983c --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx @@ -0,0 +1,10 @@ +import { DesignViewPage } from "@/pages/design-cnstn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/design-cnstn/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/page.tsx new file mode 100644 index 000000000..cced4a877 --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/page.tsx @@ -0,0 +1,3 @@ +import { DesignListPage } from "@/pages/design-cnstn"; + +export default DesignListPage; diff --git a/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx new file mode 100644 index 000000000..567b634d5 --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx @@ -0,0 +1,12 @@ +import { DesignRegistPage } from "@/pages/design-cnstn"; +import { parseTaskSeComCdFromApi } from "@/pages/design-cnstn/lib/design-cnstn-regist-schema"; + +interface PageProps { + searchParams: Promise<{ taskSeComCd?: string }>; +} + +export default async function Page({ searchParams }: PageProps) { + const params = await searchParams; + const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd); + return ; +} diff --git a/apps/as/src/entities/code/ui/code-select.tsx b/apps/as/src/entities/code/ui/code-select.tsx index e803e9285..74d011bcd 100644 --- a/apps/as/src/entities/code/ui/code-select.tsx +++ b/apps/as/src/entities/code/ui/code-select.tsx @@ -18,13 +18,7 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef className?: string; } -function CodeSelect({ - upCd, - useYn, - placeholder = "선택", - className, - ...props -}: CodeSelectProps) { +function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) { const listQuery = useCodeList(useYn ? undefined : upCd); const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? ""); @@ -34,7 +28,7 @@ function CodeSelect({ return ( { + setSidoComCd(value === ALL_SELECT_VALUE ? "" : value); + setSggComCd(""); + }} + > + + + + + 전체 + {sidoOptions.map((option) => ( + + {option.adstDstrNm} + + ))} + + + +
+ + +
+
+ + +
+
+ + setBizNm(event.target.value)} + placeholder="사업명" + /> +
+ + + + + + + ); +} + +export { DesignListPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-list.tsx b/apps/as/src/pages/design-cnstn/ui/design-list.tsx new file mode 100644 index 000000000..8a0986379 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-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 { + type DesignCnstnMngResponseDto, + type DesignCnstnMngSearchDto, + useDesignCnstnMngPage, +} from "@/entities/design-cnstn"; +import { TaskSePickerDialog } from "@/widgets/pbadms-sprt"; +import { Button } from "@common/components"; + +import { formatTaskSeComCdForApi } from "../lib/design-cnstn-regist-schema"; +import { createDesignCnstnColumns } from "../model/columns"; + +interface DesignListProps { + searchParams: DesignCnstnMngSearchDto; + pagination: PaginationState; + onPaginationChange: (pagination: PaginationState) => void; +} + +function DesignList({ searchParams, pagination, onPaginationChange }: DesignListProps) { + 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 ?? "designCnstnDocNo,desc", + }), + [searchParams, pagination], + ); + + const pageQuery = useDesignCnstnMngPage(queryParams); + const columns = useMemo(() => createDesignCnstnColumns(), []); + + const handleRowClick = (row: DesignCnstnMngResponseDto) => { + const canEdit = row.prgrsSttsComCd === "CV" && row.rgtr === userInfo?.userUnqId; + + router.push( + canEdit + ? `/design-cnstn/${row.designCnstnDocNo}/edit` + : `/design-cnstn/${row.designCnstnDocNo}`, + ); + }; + + 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(`/design-cnstn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`); + }; + + return ( + <> +
+
+

설계자문 목록

+ +
+ {tableComponent} +
+ + + ); +} + +export { DesignList }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx new file mode 100644 index 000000000..fa8130898 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx @@ -0,0 +1,11 @@ +import { DesignFormPage } from "./design-form-page"; + +interface DesignRegistPageProps { + taskSeComCd?: string[]; +} + +function DesignRegistPage({ taskSeComCd }: DesignRegistPageProps) { + return ; +} + +export { DesignRegistPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx new file mode 100644 index 000000000..aefd42317 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useMemo, useRef } from "react"; + +import { useUserStore } from "@/shared/lib"; +import { useDesignCnstnMng } from "@/entities/design-cnstn"; +import { ElaprDocPrgrsStatus } from "@/entities/elapr"; +import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Skeleton } from "@common/components"; + +import { needsRoleUser01AtrzResolve } from "../lib/button-config"; +import { getEditableReviewStep } from "../lib/review-step-utils"; + +import { FloatingActionButtons } from "./components/floating-action-buttons"; +import { AttachmentsSection } from "./sections/attachments-section"; +import { BasicInfoSection } from "./sections/basic-info-section"; +import { BusinessScaleSection } from "./sections/business-scale-section"; +import { ContactInfoSection } from "./sections/contact-info-section"; +import { + TechReviewOpinionSection, + type TechReviewOpinionSectionHandle, +} from "./sections/tech-review-opinion-section"; + +interface DesignViewPageProps { + id: string; +} + +function DesignViewPage({ id }: DesignViewPageProps) { + const userInfo = useUserStore((state) => state.userInfo); + const techReviewOpinionRef = useRef(null); + const { data, isLoading, isError } = useDesignCnstnMng(id); + + const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || ""; + const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd); + const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList( + {}, + { enabled: needsAtrzResolve }, + ); + const atrzSeComCdList = useMemo( + () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [], + [rvwLoginUserData], + ); + + if (isLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (isError || !data?.detDto) { + return
상세 정보를 찾을 수 없습니다.
; + } + + const detDto = data.detDto; + const bizSclList = data.bizSclList ?? []; + const esntlFileList = data.esntlFileList ?? []; + const etcFileList = data.etcFileList ?? []; + const rvwList = data.rvwList ?? []; + + const editableReviewStep = getEditableReviewStep( + userInfo?.dtlInstCd, + detDto.prgrsSttsComCd || "", + ); + + return ( +
+ + + + + + + + + +
+ ); +} + +export { DesignViewPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx new file mode 100644 index 000000000..082881231 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { formatFileSize } from "@/shared/lib/file-utils"; +import { useDownloadAttachmentByAtflCd } from "@/entities/attach"; +import type { + AtflMngClientResponseDto, + EsntlAtchFileMngTreeResponseDto, +} from "@/entities/design-cnstn"; +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 ( + <> + {esntlFileList.length > 0 && ( + +
+ + + + 필수 자료명 + 파일명 + 크기 + + + + {esntlFileList.map((file) => ( + + + {file.esntlAtchFileNm} + {file.esntlAtchYn === "Y" ? ( + * + ) : null} + + + {file.atflCd ? ( + downloadAttachment(file.atflCd ?? "")} + target="_blank" + rel="noreferrer" + > + {file.orgnlFileNm || "다운로드"} + + ) : ( + 첨부없음 + )} + + {formatFileSize(file.atflFlieSz)} + + ))} + +
+
+
+ )} + + +
+ + + + 파일명 + 크기 + + + + {etcFileList.length === 0 ? ( + + + 등록된 기타첨부파일이 없습니다. + + + ) : ( + etcFileList.map((file) => ( + + + {file.atflCd ? ( + downloadAttachment(file.atflCd)} + target="_blank" + rel="noreferrer" + > + {file.orgnlFileNm || file.physFileNm || file.atflId} + + ) : ( + {file.orgnlFileNm || file.physFileNm || file.atflId} + )} + + + {formatFileSize(file.atflFlieSz)} + + + )) + )} + +
+
+
+ + ); +} + +export { AttachmentsSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx new file mode 100644 index 000000000..124370ddc --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { useUserStore } from "@/shared/lib"; +import { Field, FieldError, FieldGroup } 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, + Input, + Table, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema"; + +function BasicInfoFormSection() { + const userInfo = useUserStore((state) => state.userInfo); + const { control, watch, setValue } = useFormContext(); + const [isPickerOpen, setIsPickerOpen] = useState(false); + const taskSeComCd = watch("designCnstnMng.taskSeComCd"); + const { data: taskOptions } = useCodeList("CNFM"); + + function handleBnsSelect(data: BnsApplyData) { + setValue("designCnstnMng.bizNm", data.bizNm); + setValue("designCnstnMng.bizBgngYr", data.bizBgngYr); + setValue("designCnstnMng.bizEndYr", data.bizEndYr); + setValue("designCnstnMng.bizCn", data.bizCn); + } + + const taskSeComCdLabel = + taskSeComCd + .map((code) => findCodeLabel(taskOptions, code)) + .filter(Boolean) + .join(", ") || "-"; + + return ( + + + + + + + + + + + + 사업명 + + + ( + +
+ + +
+ +
+ )} + /> +
+
+ + 사업기간 + + + ( + + + + + )} + /> + ~ + ( + + + + + )} + /> + + + + + 사업내용 + + ( + + + + + )} + /> + + + + 구분 + {taskSeComCdLabel} + 신청 지자체 + {userInfo?.instSeNm} + +
+
+ + setIsPickerOpen(false)} + onSelect={handleBnsSelect} + /> +
+ ); +} + +export { BasicInfoFormSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx new file mode 100644 index 000000000..c5db62a50 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface BasicInfoSectionProps { + det: DesignCnstnMngResponseDto; +} + +function BasicInfoSection({ det }: BasicInfoSectionProps) { + return ( + + + + + + + + + + + 사업명 + {det.bizNm || "-"} + 사업기간 + {(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")} + + + 사업내용 + {det.bizCn || "-"} + + + 환경청구분 + {det.envoSeCdNm || "-"} + 구분 + {det.taskSeCdNm || "-"} + + + 신청지자체 + + {det.sido || "-"} {det.gugun || "-"} + + + +
+
+ ); +} + +export { BasicInfoSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx new file mode 100644 index 000000000..c18e6cf3e --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { BizSclTreeDto } from "@/entities/design-cnstn"; +import { fmtNumber, indentPx, 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/design-cnstn/ui/sections/contact-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx new file mode 100644 index 000000000..ae495450e --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface ContactInfoSectionProps { + det: DesignCnstnMngResponseDto; +} + +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/design-cnstn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx new file mode 100644 index 000000000..9c6985ffb --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx @@ -0,0 +1,342 @@ +"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 { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn"; +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: DesignCnstnRvwOpnnResponseDto[]; + editableReviewStep?: ReviewStepCode; + atrzSeComCdList?: string[]; + isAtrzLoading?: boolean; +} + +export interface TechReviewOpinionSectionHandle { + getFormData: () => ReviewOpinionGetFormDataResult | null; + resetForm: () => void; +} + +interface TechReviewOpinionTableProps { + items: DesignCnstnRvwOpnnResponseDto[]; +} + +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, + atrzSeComCdList = [], + isAtrzLoading = false, +}, ref) { + const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); + 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 (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) { + return null; + } + + return { + formData: { + ...formData, + opnrSeComCd: editableReviewStep, + }, + rvwList, + }; + }, + resetForm: () => { + setFormData({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + setUploadedFile(null); + }, + }), + [canWriteOpinion, editableReviewStep, formData, rvwList], + ); + + const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); + const hasKecoSection = + kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion); + + if (!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 || !canWriteOpinion) return null; + + const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; + + return ( +
+

검토의견 작성

+ + + + 의견내용 + +