From 41ad33310aafc4c165b2dd48615cc1f98b1c33f5 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 10 Sep 2026 17:50:56 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20feat:=20=EC=8B=A4=EB=AC=B4=ED=98=91?= =?UTF-8?q?=EC=9D=98=EC=84=9C=20=EA=B4=80=EB=A6=AC=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EB=82=B4=20=EC=B2=A8=EB=B6=80?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/pwrk-cnsltn-doc-crt-rslt-service.ts | 18 +- .../pwrk-cnsltn-doc-crt-rslt-interface.ts | 8 +- ...pwrk-cnsltn-doc-crt-rslt-payload-schema.ts | 3 + .../model/pwrk-cnsltn-mng-interface.ts | 1 + .../components/pwrk-cnsltn-doc-mng-dialog.tsx | 246 ++++++++++++++++-- .../widgets/hangulgiangi/ui/hangul-giangi.tsx | 2 +- 6 files changed, 255 insertions(+), 23 deletions(-) diff --git a/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/api/pwrk-cnsltn-doc-crt-rslt-service.ts b/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/api/pwrk-cnsltn-doc-crt-rslt-service.ts index f79df9e19..82346e7c4 100644 --- a/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/api/pwrk-cnsltn-doc-crt-rslt-service.ts +++ b/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/api/pwrk-cnsltn-doc-crt-rslt-service.ts @@ -5,6 +5,7 @@ import type { PwrkCnsltnDocCrtRsltResponseDto, PwrkCnsltnDocCrtRsltService, } from "../model/pwrk-cnsltn-doc-crt-rslt-interface"; +import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "../model/pwrk-cnsltn-doc-crt-rslt-payload-schema"; const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT; @@ -35,7 +36,22 @@ async function downloadLatest(crtDmndUuid: string) { }); } -async function saveMultipart(formData: FormData): Promise { +async function saveMultipart( + saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto, + file?: Blob | File, + fileName?: string, +): Promise { + const formData = new FormData(); + formData.append("crtDmndUuid", saveDto.crtDmndUuid); + formData.append("pwrkCnsltnDocNo", saveDto.pwrkCnsltnDocNo); + if (saveDto.atchFileGroupId) { + formData.append("atchFileGroupId", saveDto.atchFileGroupId); + } + if (file) { + const resolvedFileName = fileName ?? (file instanceof File ? file.name : "document.hwpx"); + formData.append("file", file, resolvedFileName); + } + const response = await client.post(BASE, formData, { headers: { "Content-Type": undefined }, }); diff --git a/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-interface.ts b/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-interface.ts index 8f09aa23e..338e4ce5c 100644 --- a/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-interface.ts +++ b/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-interface.ts @@ -1,5 +1,7 @@ import type { AxiosResponse } from "axios"; +import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "./pwrk-cnsltn-doc-crt-rslt-payload-schema"; + export interface PwrkCnsltnDocCrtRsltResponseDto { crtDmndUuid: string; verSn: number; @@ -21,7 +23,11 @@ export interface PwrkCnsltnDocCrtRsltService { openWebHwpLatest(crtDmndUuid: string): Promise>; download(crtDmndUuid: string, verSn: number): Promise>; downloadLatest(crtDmndUuid: string): Promise>; - saveMultipart(formData: FormData): Promise; + saveMultipart( + saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto, + file?: Blob | File, + fileName?: string, + ): Promise; updateMultipart( crtDmndUuid: string, verSn: number, diff --git a/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-payload-schema.ts b/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-payload-schema.ts index 0428cdb77..c9ed3b7e1 100644 --- a/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-payload-schema.ts +++ b/apps/as/src/entities/pwrk-cnsltn-doc-crt-rslt/model/pwrk-cnsltn-doc-crt-rslt-payload-schema.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +/** 백엔드 PwrkCnsltnDocCrtRsltSaveRequestDto (orgnlOtptDocCn은 multipart file로 전송) */ export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({ crtDmndUuid: z.string().uuid(), + pwrkCnsltnDocNo: z.string().min(1).max(100), + atchFileGroupId: z.string().max(30).optional(), }); export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer< diff --git a/apps/as/src/entities/pwrk-cnsltn-mng/model/pwrk-cnsltn-mng-interface.ts b/apps/as/src/entities/pwrk-cnsltn-mng/model/pwrk-cnsltn-mng-interface.ts index 7c67a5477..fe5c4e5a4 100644 --- a/apps/as/src/entities/pwrk-cnsltn-mng/model/pwrk-cnsltn-mng-interface.ts +++ b/apps/as/src/entities/pwrk-cnsltn-mng/model/pwrk-cnsltn-mng-interface.ts @@ -24,6 +24,7 @@ export interface PwrkCnsltnMngDetResponseDto { upDocSeComCd?: string; prgrsSttsComCd?: string; bizNm?: string; + atchFileGroupId?: string; crtDmndList?: PwrkCnsltnDocCrtDmndResponseDto[]; crtRsltList?: PwrkCnsltnDocCrtRsltResponseDto[]; } diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx index dddb878d8..2e9ac175d 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx @@ -1,12 +1,22 @@ "use client"; +import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Paperclip, X } from "lucide-react"; -import { useAlertStore } from "@/shared/lib"; -import { parseFilenameFromResponse } from "@/entities/attach"; -import { pwrkCnsltnDocCrtRsltService } from "@/entities/pwrk-cnsltn-doc-crt-rslt"; +import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib"; +import { TextShimmer } from "@/shared/ui"; +import { + attachService, + parseFilenameFromResponse, + useDownloadAttachmentByAtflId, +} from "@/entities/attach"; +import { + pwrkCnsltnDocCrtRsltSaveRequestSchema, + pwrkCnsltnDocCrtRsltService, +} from "@/entities/pwrk-cnsltn-doc-crt-rslt"; import { findCompletedPwrkDraft, pwrkCnsltnMngKeys, @@ -30,15 +40,32 @@ const HangulGiangi = dynamic( }, ); +const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::"; + interface PwrkCnsltnDocMngDialogProps { open: boolean; fnrsCnsltnDocNo: string; onClose: () => void; } +interface UploadedAtchFile { + fileName: string; + fileKey: string; + atflId?: string; + atflCd?: string; +} + +function createPwrkAtchUploadKey(file: File) { + return `${PWRK_ATCH_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; +} + function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDocMngDialogProps) { const queryClient = useQueryClient(); const alert = useAlertStore((state) => state.setMessage); + const downloadByAtflId = useDownloadAttachmentByAtflId(); + const [atchFileGroupId, setAtchFileGroupId] = useState(""); + const [uploadedFiles, setUploadedFiles] = useState([]); + const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading, @@ -46,6 +73,7 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo } = usePwrkCnsltnMngByUpDocNo(fnrsCnsltnDocNo, open); const completedDraft = findCompletedPwrkDraft(pwrkCnsltnData); const crtDmndUuid = completedDraft?.crtDmndUuid; + const { data: documentBlob, isLoading: isDocumentLoading, @@ -56,32 +84,84 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo select: (response) => response.data, enabled: open && Boolean(crtDmndUuid), }); + const documentFileName = `실무협의서_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`; const isLoading = isPwrkCnsltnLoading || isDocumentLoading; const isError = isPwrkCnsltnError || isDocumentError; + const { uploadFileAsync, isUploading, uploads } = useTus({ + onSuccess: (atflCd, atflId, _file, fileKey) => { + if (!atflCd || !atflId) return; + setAtchFileGroupId(atflCd); + setUploadedFiles((prev) => + prev.map((item) => (item.fileKey === fileKey ? { ...item, atflCd, atflId } : item)), + ); + }, + onError: (_error, _file, fileKey) => { + setUploadedFiles((prev) => prev.filter((item) => item.fileKey !== fileKey)); + }, + }); + + useEffect(() => { + if (!open) { + setAtchFileGroupId(""); + setUploadedFiles([]); + return; + } + + const initialGroupId = pwrkCnsltnData?.atchFileGroupId ?? ""; + setAtchFileGroupId(initialGroupId); + + if (!initialGroupId) { + setUploadedFiles([]); + return; + } + + let cancelled = false; + void attachService.findByAtflCd(initialGroupId).then((files) => { + if (cancelled) return; + setUploadedFiles( + files.map((file) => ({ + fileName: file.orgnlFileNm, + fileKey: `${PWRK_ATCH_FILE_KEY_PREFIX}${file.atflId}`, + atflId: file.atflId, + atflCd: file.atflCd, + })), + ); + }); + + return () => { + cancelled = true; + }; + }, [open, pwrkCnsltnData?.atchFileGroupId]); + function handleOpenChange(nextOpen: boolean) { if (!nextOpen) onClose(); } async function handleSaveDocument(file: Blob) { - const crtRslt = completedDraft?.crtRslt; - if (!completedDraft || crtRslt?.verSn == null) { - throw new Error("저장할 실무협의서 초안이 없습니다."); - } + if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다."); - const formData = new FormData(); - formData.append("file", file, documentFileName); - - await pwrkCnsltnDocCrtRsltService.updateMultipart( - completedDraft.crtDmndUuid, - crtRslt.verSn, - formData, - ); - await queryClient.invalidateQueries({ - queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo), + const saveDto = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({ + crtDmndUuid: completedDraft.crtDmndUuid, + pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo, + atchFileGroupId: atchFileGroupId || undefined, }); - alert("실무협의서를 저장했습니다."); + + const savedDocument = await pwrkCnsltnDocCrtRsltService.saveMultipart( + saveDto, + file, + documentFileName, + ); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo), + }), + queryClient.invalidateQueries({ + queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", completedDraft.crtDmndUuid], + }), + ]); + alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`); } async function handleDownload() { @@ -98,6 +178,64 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo window.URL.revokeObjectURL(objectUrl); } + async function handleAtchFileUpload(files: FileList | null) { + if (!files || files.length === 0) return; + + const fileArray = Array.from(files); + let groupId = atchFileGroupId; + + for (const file of fileArray) { + const fileKey = createPwrkAtchUploadKey(file); + + setUploadedFiles((prev) => [...prev, { fileName: file.name, fileKey }]); + + try { + const atflCd = await uploadFileAsync(file, { + fileKey, + metadata: groupId ? { atflCd: groupId } : undefined, + }); + + if (!groupId && atflCd) { + groupId = atflCd; + setAtchFileGroupId(atflCd); + } + } catch { + // onError callback handles cleanup + } + } + } + + async function handleAtchFileRemove(fileIndex: number) { + const fileToRemove = uploadedFiles[fileIndex]; + if (!fileToRemove) return; + + uploads[fileToRemove.fileKey]?.abort(); + + if (fileToRemove.atflId) { + try { + await attachService.deleteAttachmentByAtflId(fileToRemove.atflId); + } catch (error) { + console.error("실무협의서 첨부파일 삭제 실패:", error); + alert("첨부파일 삭제에 실패했습니다."); + return; + } + } + + const nextFiles = uploadedFiles.filter((_, index) => index !== fileIndex); + setUploadedFiles(nextFiles); + + if (nextFiles.length === 0) { + setAtchFileGroupId(""); + } + } + + function handleAtchFileDownload(atflId?: string) { + if (!atflId) return; + downloadByAtflId.mutate(atflId); + } + + const isAnyAtchUploading = uploadedFiles.some((item) => isUploading[item.fileKey]); + return ( : null} {isError ? ( -

+

실무협의서 정보를 불러오지 못했습니다.

) : null} {!isLoading && !isError && !completedDraft ? ( -

+

생성 완료된 실무협의서 초안이 없습니다.

) : null} @@ -131,6 +269,74 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo ) : null ) : null} + {completedDraft ? ( +
+
+

첨부파일

+ +
+ {uploadedFiles.length > 0 ? ( +
+ {uploadedFiles.map((item, index) => { + const uploading = isUploading[item.fileKey] ?? false; + + return ( +
+ + + +
+ ); + })} +
+ ) : ( +

등록된 첨부파일이 없습니다.

+ )} +
+ ) : null} +