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 (
+
+ );
+ },
}),
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 (