From f584e21480f6e8d39be92052c2e8fa072cde5c03 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 16 Jul 2026 15:17:43 +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=EB=B3=B4=EC=99=84=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instl-sncn/api/instl-sncn-service.ts | 9 + .../as/src/entities/instl-sncn/api/queries.ts | 13 + apps/as/src/entities/instl-sncn/index.ts | 2 + .../instl-sncn/model/instl-sncn-interface.ts | 10 +- .../pages/instl-sncn/lib/form-data-mapper.ts | 20 +- .../lib/instl-sncn-regist-schema.ts | 18 + .../instl-sncn/lib/save-payload-utils.ts | 2 +- .../ui/components/floating-action-buttons.tsx | 41 ++- .../pages/instl-sncn/ui/instl-form-page.tsx | 66 +++- .../pages/instl-sncn/ui/instl-view-page.tsx | 12 + .../ui/sections/supplement-demand-section.tsx | 181 ++++++++++ .../sections/tech-review-opinion-section.tsx | 16 +- apps/as/src/widgets/pbadms-sprt/index.ts | 1 + .../ui/splmnt-dmnd-form-section.tsx | 324 ++++++++++++++++++ 14 files changed, 683 insertions(+), 32 deletions(-) create mode 100644 apps/as/src/pages/instl-sncn/ui/sections/supplement-demand-section.tsx create mode 100644 apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx diff --git a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts index 5739b82bb..2bef72628 100644 --- a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts +++ b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts @@ -65,6 +65,14 @@ async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Pr await client.post(`${BASE}/${id}/tech-review`, payload); } +async function submitSupplement( + id: string, + payload: InstlSncnApplySaveRequestDto, +): Promise { + const response = await client.post(`${BASE}/${id}/submitsupplement`, payload); + return response.data; +} + async function submitReqSupplement( id: string, payload: InstlSncnApplySaveRequestDto, @@ -88,6 +96,7 @@ export const instlSncnService = { saveApply, update, techReview, + submitSupplement, submitReqSupplement, remove, changePrgrsSttsComCd, diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts index 6044db955..f9d94dbff 100644 --- a/apps/as/src/entities/instl-sncn/api/queries.ts +++ b/apps/as/src/entities/instl-sncn/api/queries.ts @@ -99,6 +99,19 @@ export function useInstlSncnTechReview() { }); } +export function useInstlSncnSubmitSupplement() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) => + instlSncnService.submitSupplement(id, payload), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: instlSncnKeys.all }); + queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) }); + }, + }); +} + export function useInstlSncnSubmitReqSupplement() { const queryClient = useQueryClient(); diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index c528eca07..946e756f1 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -6,6 +6,7 @@ export { useInstlSncnSidoCombo, useInstlSncnSigunguCombo, useInstlSncnSubmitReqSupplement, + useInstlSncnSubmitSupplement, useInstlSncnTechReview, } from "./api/queries"; export { @@ -29,6 +30,7 @@ export { type InstlSncnMngSearchParams, type InstlSncnRvwOpnnResponseDto, type InstlSncnRvwOpnnSaveRequestDto, + type InstlSncnSplmntDmndResponseDto, type InstlSncnSplmntDmndSaveDto, type ReviewStepCode, } from "./model/instl-sncn-interface"; diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index 0f6fc36ee..6ead54c13 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -15,6 +15,7 @@ import type { PbadmsMngDetResponseBase, RvwOpnnResponseExtension, RvwOpnnSaveBase, + SplmntDmndResponseExtension, SplmntDmndSaveBase, } from "@/shared/model"; @@ -185,6 +186,12 @@ export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase { /** 설치인가 보완요구 저장 DTO(배열 원소) */ export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase; +/** 설치인가 보완요구 응답 DTO */ +export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto & + SplmntDmndResponseExtension & { + instlSncnDocNo?: string; + }; + /** 설치인가 신청 - 통합 저장 요청 DTO */ export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader { /** 설치인가관리 정보 */ @@ -225,7 +232,7 @@ export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto & export interface InstlSncnMngDetResponse extends PbadmsMngDetResponseBase< InstlSncnMngResponseDto, InstlSncnRvwOpnnResponseDto, - InstlSncnSplmntDmndSaveDto + InstlSncnSplmntDmndResponseDto > { detDto: InstlSncnMngResponseDto; bizSclList: BizSclTreeDto[]; @@ -242,6 +249,7 @@ export interface InstlSncnMngService { saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise; update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise; techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; + submitSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; remove: (id: string) => Promise; changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise; diff --git a/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts index 0b02454f8..a0a36a298 100644 --- a/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts +++ b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts @@ -24,7 +24,7 @@ function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["esntlFileL } export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnRegistFormData { - const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList } = data; + const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList, splmntDmndList } = data; return { instlSncnMng: { @@ -85,6 +85,23 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg picRvwCn: item.picRvwCn, })) ?? [], esntlAtchFileList: mapEsntlAtchFileList(esntlFileList), + splmntDmndList: + splmntDmndList?.map((item) => ({ + splmntDmndSn: item.splmntDmndSn, + splmntArtclComCd: item.splmntArtclComCd, + splmntArtclCdNm: item.splmntArtclCdNm, + splmntDmndCn: item.splmntDmndCn, + splmntDmndDt: item.splmntDmndDt, + splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "", + splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "", + splmntCn: item.splmntCn ?? "", + splmntPicNm: item.splmntPicNm ?? "", + splmntCmptnDt: item.splmntCmptnDt ?? "", + splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "", + splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "", + splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "", + cmptnYn: item.cmptnYn, + })) ?? [], privacyConsent: "Y", }; } @@ -116,6 +133,7 @@ export function getCreateDefaultValues( bizCstList: [], cklstResultList: [], esntlAtchFileList: [], + splmntDmndList: [], privacyConsent: "N", }; } diff --git a/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts b/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts index 64865d49c..5cc52d296 100644 --- a/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts +++ b/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts @@ -75,6 +75,24 @@ export const instlSncnRegistSchema = z.object({ atchFileGroupId: z.string(), }), ), + splmntDmndList: z.array( + z.object({ + splmntDmndSn: z.number().optional(), + splmntArtclComCd: z.string().optional(), + splmntArtclCdNm: z.string().nullish(), + splmntDmndCn: z.string().optional(), + splmntDmndDt: z.string().optional(), + splmntDmndAtchFileGroupId: z.string().optional(), + splmntDmndAtchFileNm: z.string().optional(), + splmntCn: z.string().optional(), + splmntPicNm: z.string().optional(), + splmntCmptnDt: z.string().optional(), + splmntAtchFileGroupId: z.string().optional(), + splmntAtchFileNm: z.string().optional(), + splmntAtchFileAtflId: z.string().optional(), + cmptnYn: z.string().optional(), + }), + ), privacyConsent: z.enum(["Y", "N"], { required_error: "개인정보 수집/이용 동의는 필수입니다", }), 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 index 209c02a3c..bb9bc18ab 100644 --- a/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts +++ b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts @@ -115,7 +115,7 @@ export function buildSupplementReqPayload( }; } -function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto { +export function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto { return { splmntDmndSn: item.splmntDmndSn, splmntArtclComCd: item.splmntArtclComCd, 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 a65ddc291..0f36c1ee6 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 @@ -41,8 +41,10 @@ import { buildSavePayload, buildSupplementReqPayload, buildTechReviewReplyPayload, + toSplmntDmndSaveItem, } from "../../lib/save-payload-utils"; import { type ChecklistFormState } from "../../model/view-types"; +import type { SupplementDemandSectionHandle } from "../sections/supplement-demand-section"; import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section"; interface FloatingActionButtonsProps { @@ -53,6 +55,7 @@ interface FloatingActionButtonsProps { dtlInstCd: string | undefined; checklistFormState: ChecklistFormState; techReviewOpinionRef?: React.RefObject; + supplementDemandRef?: React.RefObject; opnrNm?: string; } @@ -66,6 +69,7 @@ function FloatingActionButtons({ dtlInstCd, checklistFormState, techReviewOpinionRef, + supplementDemandRef, opnrNm, }: FloatingActionButtonsProps) { const router = useRouter(); @@ -383,15 +387,20 @@ function FloatingActionButtons({ return; } - const payload = buildTechReviewReplyPayload( - data, - checklistFormState, - "KECO", - "TE", - "SV", - getReviewOpinionFormData(), - opnrNm, - ); + const payload = { + ...buildTechReviewReplyPayload( + data, + checklistFormState, + "KECO", + "TE", + "SV", + getReviewOpinionFormData(), + opnrNm, + ), + splmntDmndList: + supplementDemandRef?.current?.getSplmntDmndList() ?? + (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem), + }; await executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn, @@ -566,9 +575,17 @@ async function handleDel(context: ActionHandlerContext): Promise { } } -async function handleSplmntCmptn(context: ActionHandlerContext): Promise { - // TODO: 보완완료 API 호출 - context.onSuccess?.(); +async function handleSplmntCmptn( + context: ActionHandlerContext, +): Promise { + try { + const docNo = context.data.detDto?.instlSncnDocNo; + if (!docNo) throw new Error("문서번호가 없습니다."); + await instlSncnService.submitSupplement(docNo, context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } } async function handleChangePrgrsSttsComCd( diff --git a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx index 1e5230384..2b0e1fe1b 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx @@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAlertStore, useUserStore } from "@/shared/lib"; import { + type InstlSncnApplySaveRequestDto, instlSncnKeys, type InstlSncnMngDetResponse, instlSncnService, @@ -22,6 +23,7 @@ import { ContactInfoFormSection, isFormChecklistComplete, PrivacyConsentSection, + SplmntDmndFormSection, } from "@/widgets/pbadms-sprt"; import { Button } from "@common/components"; @@ -59,13 +61,13 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp : getCreateDefaultValues(userInfo, taskSeComCd), }); - async function submitForm( + function buildFormPayload( data: InstlSncnRegistFormData, - prgrsSttsComCd: "SV" | "ST", - ): Promise { + prgrsSttsComCd: "SV" | "ST" | "TE", + ): InstlSncnApplySaveRequestDto { const { taskSeComCd: formTaskSeComCd, ...instlSncnMngRest } = data.instlSncnMng; - const payload = { + return { instlSncnMng: { ...instlSncnMngRest, instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined, @@ -79,9 +81,50 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp selected: item.selected, })), esntlAtchFileList: data.esntlAtchFileList, + splmntDmndList: data.splmntDmndList.map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => item, + ), }; + } - return instlSncnService.saveApply(payload); + async function submitForm( + data: InstlSncnRegistFormData, + prgrsSttsComCd: "SV" | "ST", + ): Promise { + return instlSncnService.saveApply(buildFormPayload(data, prgrsSttsComCd)); + } + + async function submitSupplementForm(data: InstlSncnRegistFormData): Promise { + const docNo = initialData?.detDto.instlSncnDocNo; + if (!docNo) return undefined; + return instlSncnService.submitSupplement(docNo, buildFormPayload(data, "TE")); + } + + async function onSplmntCmptn(data: InstlSncnRegistFormData) { + if (isSubmitting) return; + + const docNo = initialData?.detDto.instlSncnDocNo; + if (!docNo) { + alert("문서번호가 없습니다."); + return; + } + + try { + setIsSubmitting(true); + const result = await submitSupplementForm(data); + if (!result) return; + + alert("보완완료 처리되었습니다."); + router.push(`/instl-sncn/${docNo}`); + queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() }); + queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(docNo) }); + } catch (error) { + console.error("보완완료 실패:", error); + alert("보완완료 처리 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } } async function onSave(data: InstlSncnRegistFormData) { @@ -132,10 +175,6 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp } } - function handleApplyClick() { - form.handleSubmit(onApply)(); - } - async function handleDelClick() { if (isSubmitting) return; @@ -176,6 +215,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined} initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined} /> +
@@ -197,7 +237,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp )} 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 a838c4a0c..bee427e3f 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 @@ -18,6 +18,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 { + SupplementDemandSection, + type SupplementDemandSectionHandle, +} from "./sections/supplement-demand-section"; import { TechReviewOpinionSection, type TechReviewOpinionSectionHandle, @@ -30,6 +34,7 @@ interface InstlViewPageProps { function InstlViewPage({ id }: InstlViewPageProps) { const userInfo = useUserStore((state) => state.userInfo); const techReviewOpinionRef = useRef(null); + const supplementDemandRef = useRef(null); const { data, isLoading, isError } = useInstlSncnDetail(id); const { formState: checklistFormState, setFormState: setChecklistFormState } = useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST); @@ -65,6 +70,7 @@ function InstlViewPage({ id }: InstlViewPageProps) { esntlFileList, etcFileList, rvwList = [], + splmntDmndList = [], } = data; const editableReviewStep = getEditableReviewStep( @@ -94,6 +100,11 @@ function InstlViewPage({ id }: InstlViewPageProps) { rvwList={rvwList} editableReviewStep={editableReviewStep} /> +
diff --git a/apps/as/src/pages/instl-sncn/ui/sections/supplement-demand-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/supplement-demand-section.tsx new file mode 100644 index 000000000..319c79815 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/ui/sections/supplement-demand-section.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { forwardRef, useImperativeHandle, useState } from "react"; + +import { Paperclip } from "lucide-react"; + +import { dayjs } from "@/shared/lib"; +import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; +import { useDownloadAttachmentByAtflCd } from "@/entities/attach"; +import type { + InstlSncnSplmntDmndResponseDto, + InstlSncnSplmntDmndSaveDto, +} from "@/entities/instl-sncn"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { + Badge, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { toSplmntDmndSaveItem } from "../../lib/save-payload-utils"; + +interface SupplementDemandSectionProps { + splmntDmndList: InstlSncnSplmntDmndResponseDto[]; + canEditCmptnYn: boolean; +} + +export interface SupplementDemandSectionHandle { + getSplmntDmndList: () => InstlSncnSplmntDmndSaveDto[]; +} + +const SupplementDemandSection = forwardRef< + SupplementDemandSectionHandle, + SupplementDemandSectionProps +>(function SupplementDemandSection({ splmntDmndList, canEditCmptnYn }, ref) { + const [cmptnYnBySn, setCmptnYnBySn] = useState>({}); + const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd(); + + useImperativeHandle( + ref, + () => ({ + getSplmntDmndList: () => + splmntDmndList.map((item) => { + const saveItem = toSplmntDmndSaveItem(item); + const sn = item.splmntDmndSn; + if (sn == null || cmptnYnBySn[sn] == null) return saveItem; + return { ...saveItem, cmptnYn: cmptnYnBySn[sn] }; + }), + }), + [splmntDmndList, cmptnYnBySn], + ); + + function getCmptnYn(item: InstlSncnSplmntDmndResponseDto) { + const sn = item.splmntDmndSn; + if (sn != null && cmptnYnBySn[sn] != null) return cmptnYnBySn[sn]; + return item.cmptnYn ?? "N"; + } + + return ( + +
+ + + + + + + + + + + + 보완항목 + 보완요청일시 + 보완요청내용 + 보완완료일시 + 보완완료내용 + 완료여부 + + + + {splmntDmndList.map((item) => { + const sn = item.splmntDmndSn; + const cmptnYn = getCmptnYn(item); + const dmndAtchFileNm = item.splmntDmndAtchFileInfo?.orgnlFileNm; + const cmptnAtchFileNm = item.splmntAtchFileInfo?.orgnlFileNm; + + return ( + + + {item.splmntArtclCdNm || "-"} + + + {item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"} + + +
+
{item.splmntDmndCn || "-"}
+ {item.splmntDmndAtchFileGroupId ? ( + downloadAttachment(item.splmntDmndAtchFileGroupId!)} + > + + {dmndAtchFileNm || "다운로드"} + + ) : null} +
+
+ + {item.splmntCmptnDt + ? dayjs(item.splmntCmptnDt).format("YYYY-MM-DD HH:mm") + : "-"} + + +
+
{item.splmntCn || "-"}
+ {item.splmntAtchFileGroupId ? ( + downloadAttachment(item.splmntAtchFileGroupId!)} + > + + {cmptnAtchFileNm || "다운로드"} + + ) : null} +
+
+ + {canEditCmptnYn && sn != null ? ( +
+ + setCmptnYnBySn((prev) => ({ ...prev, [sn]: value })) + } + className="flex justify-center gap-4" + > + + + + 완료 + + + + + + 미완료 + + + +
+ ) : ( + + {cmptnYn === "Y" ? "완료" : "미완료"} + + )} +
+
+ ); + })} +
+
+
+
+ ); +}); + +export { SupplementDemandSection }; 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 175d31a42..c958835b6 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 @@ -167,8 +167,8 @@ const TechReviewOpinionSection = forwardRef< const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; return ( -
-

검토의견 작성

+
+

검토의견 작성

@@ -267,14 +267,18 @@ const TechReviewOpinionSection = forwardRef< <> {hasEnvSection ? ( - {envRvwList.length > 0 ? : null} - {renderOpinionForm("ENV")} +
+ {envRvwList.length > 0 ? : null} + {renderOpinionForm("ENV")} +
) : null} {hasKecoSection ? ( - {kecoRvwList.length > 0 ? : null} - {renderOpinionForm("KECO")} +
+ {kecoRvwList.length > 0 ? : null} + {renderOpinionForm("KECO")} +
) : null} diff --git a/apps/as/src/widgets/pbadms-sprt/index.ts b/apps/as/src/widgets/pbadms-sprt/index.ts index bec3bc4c6..550a07dd5 100644 --- a/apps/as/src/widgets/pbadms-sprt/index.ts +++ b/apps/as/src/widgets/pbadms-sprt/index.ts @@ -35,4 +35,5 @@ export { type SplmntDmndDialogConfirmItem, type SplmntDmndDialogConfirmPayload, } from "./ui/splmnt-dmnd-dialog"; +export { type SplmntDmndFormItem, SplmntDmndFormSection } from "./ui/splmnt-dmnd-form-section"; export { TaskSePickerDialog } from "./ui/task-se-picker-dialog"; diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx new file mode 100644 index 000000000..28cb40497 --- /dev/null +++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; + +import { Paperclip } from "lucide-react"; + +import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; +import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach"; +import { + Badge, + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import { SectionCollapsible } from "./section-collapsible"; + +const SPLMNT_CMPTN_FILE_KEY_PREFIX = "splmnt-cmptn::"; + +interface UploadedSplmntFile { + fileName: string; + fileKey: string; + atflId?: string; + atflCd?: string; +} + +interface SplmntDmndFormItem { + splmntDmndSn?: number; + splmntArtclComCd?: string; + splmntArtclCdNm?: string | null; + splmntDmndCn?: string; + splmntDmndDt?: string; + splmntDmndAtchFileGroupId?: string; + splmntDmndAtchFileNm?: string; + splmntCn?: string; + splmntPicNm?: string; + splmntCmptnDt?: string; + splmntAtchFileGroupId?: string; + splmntAtchFileNm?: string; + splmntAtchFileAtflId?: string; + cmptnYn?: string; +} + +interface SplmntDmndFormData { + splmntDmndList: SplmntDmndFormItem[]; +} + +interface SplmntDmndFormSectionProps { + readOnly?: boolean; +} + +function createUploadKey(rowKey: string, file: File) { + return `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`; +} + +function getRowKeyFromFileKey(fileKey: string): string | null { + if (!fileKey.startsWith(SPLMNT_CMPTN_FILE_KEY_PREFIX)) return null; + const rest = fileKey.slice(SPLMNT_CMPTN_FILE_KEY_PREFIX.length); + const separatorIndex = rest.indexOf("::"); + if (separatorIndex < 0) return null; + return rest.slice(0, separatorIndex); +} + +function getRowKey(item: SplmntDmndFormItem, index: number) { + return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`; +} + +function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) { + const formId = useId(); + const { control, setValue, getValues } = useFormContext(); + const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? []; + const [uploadedFiles, setUploadedFiles] = useState>({}); + const initialFilesMappedRef = useRef(false); + const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd(); + + const { uploadFileAsync, isUploading, uploads } = useTus({ + onSuccess: (atflCd, atflId, _file, fileKey) => { + if (!atflCd || !atflId) return; + const rowKey = getRowKeyFromFileKey(fileKey); + if (!rowKey) return; + + setUploadedFiles((prev) => { + const current = prev[rowKey]; + if (!current || current.fileKey !== fileKey) return prev; + return { + ...prev, + [rowKey]: { ...current, atflCd, atflId }, + }; + }); + + const list = getValues("splmntDmndList") ?? []; + const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey); + if (index < 0) return; + + setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true }); + setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true }); + }, + onError: (_error, _file, fileKey) => { + const rowKey = getRowKeyFromFileKey(fileKey); + if (!rowKey) return; + + setUploadedFiles((prev) => { + const current = prev[rowKey]; + if (!current || current.fileKey !== fileKey) return prev; + return { ...prev, [rowKey]: null }; + }); + }, + }); + + useEffect(() => { + if (initialFilesMappedRef.current) return; + if (splmntDmndList.length === 0) return; + + const initial: Record = {}; + for (let index = 0; index < splmntDmndList.length; index += 1) { + const item = splmntDmndList[index]; + const rowKey = getRowKey(item, index); + if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) { + initial[rowKey] = { + fileName: item.splmntAtchFileNm, + fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`, + atflCd: item.splmntAtchFileGroupId, + atflId: item.splmntAtchFileAtflId, + }; + } else { + initial[rowKey] = null; + } + } + + setUploadedFiles(initial); + initialFilesMappedRef.current = true; + }, [splmntDmndList]); + + if (splmntDmndList.length === 0) return null; + + async function handleFileUpload(index: number, files: FileList | null) { + if (!files || files.length === 0) return; + + const item = splmntDmndList[index]; + const rowKey = getRowKey(item, index); + const file = files[0]; + const fileKey = createUploadKey(rowKey, file); + const existingAtflCd = item.splmntAtchFileGroupId; + + setUploadedFiles((prev) => ({ + ...prev, + [rowKey]: { fileName: file.name, fileKey }, + })); + setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true }); + + try { + await uploadFileAsync(file, { + fileKey, + metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, + }); + } catch { + // onError callback handles cleanup + } + } + + async function handleFileRemove(index: number) { + const item = splmntDmndList[index]; + const rowKey = getRowKey(item, index); + const uploaded = uploadedFiles[rowKey]; + if (!uploaded) return; + + const { fileKey, atflId } = uploaded; + uploads[fileKey]?.abort(); + + if (atflId) { + try { + await attachService.deleteAttachmentByAtflId(atflId); + } catch (error) { + console.error("보완완료 첨부파일 삭제 실패:", error); + } + } + + setUploadedFiles((prev) => ({ ...prev, [rowKey]: null })); + setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true }); + setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true }); + setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true }); + } + + return ( + +
+
+ + + + + + + + + + + 보완항목 + 보완요청일시 + 보완요청내용 + 보완완료내용 + 파일첨부 + 완료여부 + + + + {splmntDmndList.map((item, index) => { + const rowKey = getRowKey(item, index); + const uploaded = uploadedFiles[rowKey] ?? null; + const uploading = uploaded ? (isUploading[uploaded.fileKey] ?? false) : false; + const fileInputId = `${formId}-${rowKey}-file`; + const isRowReadOnly = readOnly || item.cmptnYn === "Y"; + + return ( + + + {item.splmntArtclCdNm || "-"} + + + {item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"} + + +
+
{item.splmntDmndCn || "-"}
+ {item.splmntDmndAtchFileGroupId ? ( + downloadAttachment(item.splmntDmndAtchFileGroupId!)} + > + + {item.splmntDmndAtchFileNm || "다운로드"} + + ) : null} +
+
+ + {isRowReadOnly ? ( + {item.splmntCn || "-"} + ) : ( + ( +