From 254986b48b1ce41b9843157049665b3e4b96d203 Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 28 Aug 2026 14:31:54 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20=ED=95=84=EC=88=98=ED=8E=8C=EB=B6=80?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EA=B4=80=EB=A6=AC=20=EB=82=B4=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=ED=8C=8C=EC=9D=BC=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/esntl/model/esntl-interface.ts | 27 ++-- .../model/esntl-atch-file-columns.tsx | 40 +++-- .../ui/esntl-atch-file-form-dialog.tsx | 152 +++++++++++++++++- .../ui/esntl-atch-file-list.tsx | 13 +- apps/as/src/shared/model/pbadms-sprt.ts | 6 +- 5 files changed, 209 insertions(+), 29 deletions(-) diff --git a/apps/as/src/entities/esntl/model/esntl-interface.ts b/apps/as/src/entities/esntl/model/esntl-interface.ts index 56fd8b1dd..0476fd5a7 100644 --- a/apps/as/src/entities/esntl/model/esntl-interface.ts +++ b/apps/as/src/entities/esntl/model/esntl-interface.ts @@ -1,18 +1,11 @@ -import type { PageableParams, PageResponse } from "@/shared/model"; +import type { + AtflMngClientResponseDto, + EsntlAtchFileMngTreeResponseDto, + PageableParams, + PageResponse, +} from "@/shared/model"; -/** 필수첨부파일관리 트리 조회 응답 DTO */ -export interface EsntlAtchFileMngTreeResponseDto { - /** 필수첨부파일아이디 */ - esntlAtchFileId: string; - /** 문서구분공통코드 */ - docSeComCd: string; - /** 필수첨부파일명 */ - esntlAtchFileNm: string; - /** 필수첨부여부 ("Y" | "N") */ - esntlAtchYn: "Y" | "N"; - /** 필수첨부파일비고 (파일형식 등) */ - esntlAtchFileRmrk: string; -} +export type { EsntlAtchFileMngTreeResponseDto }; /** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */ export interface EsntlAtchFileMng { @@ -26,6 +19,10 @@ export interface EsntlAtchFileMng { esntlAtchYn: "Y" | "N"; /** 필수첨부파일비고 */ esntlAtchFileRmrk?: string; + /** 첨부파일그룹아이디 (양식파일 그룹) */ + atchFileGroupId?: string; + /** 첨부파일 목록 (양식파일) */ + atchFileList?: AtflMngClientResponseDto[]; /** 등록자UUID */ rgtr?: string; /** 등록자명 */ @@ -80,4 +77,6 @@ export interface EsntlAtchFileMngSaveRequest { esntlAtchYn?: "Y" | "N"; /** 필수첨부파일비고 */ esntlAtchFileRmrk?: string; + /** 첨부파일그룹아이디 (양식파일 그룹) */ + atchFileGroupId?: string; } diff --git a/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx index ad5f32c62..4dd0c32bf 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx @@ -1,6 +1,7 @@ "use client"; import { createColumnHelper } from "@tanstack/react-table"; +import { Paperclip } from "lucide-react"; import type { Code } from "@/entities/code"; import { findCodeLabel } from "@/entities/code"; @@ -13,22 +14,24 @@ interface CreateEsntlAtchFileColumnsOptions { docSeCodes: Code[] | undefined; onEdit: (row: EsntlAtchFileMng) => void; onDelete: (row: EsntlAtchFileMng) => void; + onDownloadTemplate: (atflId: string) => void; } function formatYn(value: string | undefined) { if (value === "Y") return "예"; if (value === "N") return "아니오"; - return "—"; + return "-"; } function formatOptional(value: string | undefined) { - return value?.trim() ? value : "—"; + return value?.trim() ? value : "-"; } export function createEsntlAtchFileColumns({ docSeCodes, onEdit, onDelete, + onDownloadTemplate, }: CreateEsntlAtchFileColumnsOptions) { return [ columnHelper.accessor("esntlAtchFileId", { @@ -37,7 +40,7 @@ export function createEsntlAtchFileColumns({ }), columnHelper.accessor("docSeComCd", { header: "문서구분", - cell: ({ getValue }) => findCodeLabel(docSeCodes, getValue()) || getValue() || "—", + cell: ({ getValue }) => findCodeLabel(docSeCodes, getValue()) || getValue() || "-", }), columnHelper.accessor("esntlAtchFileNm", { header: "필수첨부파일명", @@ -50,18 +53,33 @@ export function createEsntlAtchFileColumns({ }), columnHelper.accessor("esntlAtchFileRmrk", { header: "비고", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("regDt", { - header: "등록일시", meta: { align: "center" }, cell: ({ getValue }) => formatOptional(getValue()), }), - columnHelper.accessor("mdfcnDt", { - header: "수정일시", + columnHelper.display({ + id: "templateDownload", + header: "양식 다운로드", meta: { align: "center" }, - cell: ({ getValue }) => formatOptional(getValue()), + cell: ({ row }) => { + const files = row.original.atchFileList ?? []; + + if (files.length === 0) return "-"; + + return ( +
e.stopPropagation()}> + {files.map((file) => ( + onDownloadTemplate(file.atflId)} + > + + {file.orgnlFileNm || "다운로드"} + + ))} +
+ ); + }, }), columnHelper.display({ id: "actions", diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx index 16d6fed1c..a45862486 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx @@ -1,8 +1,13 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useId, useState } from "react"; import { useForm } from "react-hook-form"; +import { Paperclip, X } from "lucide-react"; + +import { getDefaultFileKey, useTus } from "@/shared/lib"; +import { TextShimmer } from "@/shared/ui"; +import { attachService } from "@/entities/attach"; import { CodeSelect } from "@/entities/code"; import type { EsntlAtchFileMng } from "@/entities/esntl"; import { @@ -24,6 +29,14 @@ export interface EsntlAtchFileFormValues { esntlAtchFileNm: string; esntlAtchYn: "Y" | "N"; esntlAtchFileRmrk: string; + atchFileGroupId: string; +} + +interface UploadedTemplateFile { + fileName: string; + fileKey: string; + atflId?: string; + atflCd?: string; } interface EsntlAtchFileFormDialogProps { @@ -35,6 +48,10 @@ interface EsntlAtchFileFormDialogProps { onSubmit: (values: EsntlAtchFileFormValues) => void; } +function createUploadKey(file: File) { + return `template::${getDefaultFileKey(file)}`; +} + function EsntlAtchFileFormDialog({ mode, open, @@ -43,39 +60,116 @@ function EsntlAtchFileFormDialog({ isSubmitting, onSubmit, }: EsntlAtchFileFormDialogProps) { + const formId = useId(); + const [uploadedFiles, setUploadedFiles] = useState([]); + const { register, handleSubmit, reset, setValue, watch } = useForm({ defaultValues: { docSeComCd: "", esntlAtchFileNm: "", esntlAtchYn: "Y", esntlAtchFileRmrk: "", + atchFileGroupId: "", }, }); const docSeComCd = watch("docSeComCd"); const esntlAtchYn = watch("esntlAtchYn"); + const atchFileGroupId = watch("atchFileGroupId"); + + const { uploadFileAsync, isUploading, uploads } = useTus({ + onSuccess: (atflCd, atflId, _file, fileKey) => { + if (!atflCd || !atflId) return; + + setValue("atchFileGroupId", atflCd, { shouldDirty: true }); + setUploadedFiles((prev) => + prev.map((item) => (item.fileKey === fileKey ? { ...item, atflCd, atflId } : item)), + ); + }, + onError: (_error, _file, fileKey) => { + setUploadedFiles((prev) => prev.filter((item) => item.fileKey !== fileKey)); + }, + }); + + const isAnyUploading = uploadedFiles.some((item) => isUploading[item.fileKey] ?? false); useEffect(() => { if (!open) return; if (mode === "edit" && initial) { + const initialFiles = (initial.atchFileList ?? []) + .filter((file) => file.atflId) + .map((file) => ({ + fileName: file.orgnlFileNm ?? "양식파일", + fileKey: `server::${file.atflId}`, + atflId: file.atflId, + atflCd: file.atflCd, + })); + + setUploadedFiles(initialFiles); reset({ docSeComCd: initial.docSeComCd ?? "", esntlAtchFileNm: initial.esntlAtchFileNm ?? "", esntlAtchYn: initial.esntlAtchYn ?? "Y", esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "", + atchFileGroupId: initial.atchFileGroupId ?? "", }); return; } + setUploadedFiles([]); reset({ docSeComCd: "", esntlAtchFileNm: "", esntlAtchYn: "Y", esntlAtchFileRmrk: "", + atchFileGroupId: "", }); }, [open, mode, initial, reset]); + async function handleFileUpload(files: FileList | null) { + if (!files || files.length === 0) return; + + for (const file of Array.from(files)) { + const fileKey = createUploadKey(file); + const existingAtflCd = atchFileGroupId || uploadedFiles.find((item) => item.atflCd)?.atflCd; + + setUploadedFiles((prev) => [...prev, { fileName: file.name, fileKey }]); + + try { + await uploadFileAsync(file, { + fileKey, + metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, + }); + } catch { + // onError callback handles cleanup + } + } + } + + async function handleFileRemove(fileIndex: number) { + const target = uploadedFiles[fileIndex]; + if (!target) return; + + uploads[target.fileKey]?.abort(); + + if (target.atflId) { + try { + await attachService.deleteAttachmentByAtflId(target.atflId); + } catch (error) { + console.error("양식파일 삭제 실패:", error); + } + } + + const nextFiles = uploadedFiles.filter((_, index) => index !== fileIndex); + setUploadedFiles(nextFiles); + + const nextGroupId = nextFiles.find((item) => item.atflCd)?.atflCd ?? ""; + setValue("atchFileGroupId", nextGroupId, { shouldDirty: true }); + } + + const fileInputId = `${formId}-template-file`; + return ( @@ -109,6 +203,60 @@ function EsntlAtchFileFormDialog({ {...register("esntlAtchFileNm", { required: true })} /> +
+ +
+ {uploadedFiles.length > 0 ? ( +
+ {uploadedFiles.map((item, index) => { + const uploading = isUploading[item.fileKey] ?? false; + + return ( +
+ + + {item.fileName} + + +
+ ); + })} +
+ ) : null} + +
+
onOpenChange(false)}> 취소 - diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx index 62a728073..71d0ac080 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx @@ -6,6 +6,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; import { useDataTable } from "@/shared/lib"; +import { useDownloadAttachmentByAtflId } from "@/entities/attach"; import { useCodeList } from "@/entities/code"; import { type EsntlAtchFileMng, @@ -37,6 +38,7 @@ function EsntlAtchFileList({ }: EsntlAtchFileListProps) { const { confirm } = useConfirm(); const { data: docSeCodes } = useCodeList("ASDOCSE"); + const { mutate: downloadTemplate } = useDownloadAttachmentByAtflId(); const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); const [editingRow, setEditingRow] = useState(null); @@ -64,6 +66,13 @@ function EsntlAtchFileList({ [confirm, deleteMutation], ); + const handleDownloadTemplate = useCallback( + (atflId: string) => { + downloadTemplate(atflId); + }, + [downloadTemplate], + ); + const columns = useMemo( () => createEsntlAtchFileColumns({ @@ -73,8 +82,9 @@ function EsntlAtchFileList({ setDialogMode("edit"); }, onDelete: handleDelete, + onDownloadTemplate: handleDownloadTemplate, }), - [docSeCodes, handleDelete], + [docSeCodes, handleDelete, handleDownloadTemplate], ); const { tableComponent } = useDataTable({ @@ -102,6 +112,7 @@ function EsntlAtchFileList({ esntlAtchFileNm: values.esntlAtchFileNm, esntlAtchYn: values.esntlAtchYn, esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined, + atchFileGroupId: values.atchFileGroupId || undefined, }; if (dialogMode === "create") { diff --git a/apps/as/src/shared/model/pbadms-sprt.ts b/apps/as/src/shared/model/pbadms-sprt.ts index c90c2cdaf..455605c34 100644 --- a/apps/as/src/shared/model/pbadms-sprt.ts +++ b/apps/as/src/shared/model/pbadms-sprt.ts @@ -108,7 +108,11 @@ export interface EsntlAtchFileMngTreeResponseDto { esntlAtchYn: string; /** 필수첨부파일비고 */ esntlAtchFileRmrk: string | null; - /** 첨부파일 목록 (슬롯당 atchFileGroupId 1:N) */ + /** 첨부파일그룹아이디 (양식파일 그룹) */ + atchFileGroupId?: string; + /** 템플릿 샘플 파일 목록 (트리 조회 시 양식파일) */ + templateAtchFileList?: AtflMngClientResponseDto[]; + /** 첨부파일 목록 (문서 사용자 첨부) */ atchFileList?: AtflMngClientResponseDto[]; }