[as] 필수펌부파일관리 내 양식파일 컬럼 추가

This commit is contained in:
2026-08-28 14:31:54 +09:00
parent 4c1615853c
commit 254986b48b
5 changed files with 209 additions and 29 deletions
@@ -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;
}
@@ -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 (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
{files.map((file) => (
<a
key={file.atflId}
className="flex cursor-pointer items-start gap-0.5 text-left text-blue-600 hover:underline"
onClick={() => onDownloadTemplate(file.atflId)}
>
<Paperclip className="my-1 size-5 shrink-0" />
{file.orgnlFileNm || "다운로드"}
</a>
))}
</div>
);
},
}),
columnHelper.display({
id: "actions",
@@ -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<UploadedTemplateFile[]>([]);
const { register, handleSubmit, reset, setValue, watch } = useForm<EsntlAtchFileFormValues>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[52rem]">
@@ -109,6 +203,60 @@ function EsntlAtchFileFormDialog({
{...register("esntlAtchFileNm", { required: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor={fileInputId}>양식파일</Label>
<div className="space-y-2">
{uploadedFiles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{uploadedFiles.map((item, index) => {
const uploading = isUploading[item.fileKey] ?? false;
return (
<div
key={item.fileKey}
className="inline-flex items-center justify-between gap-2 rounded-sm border bg-accent p-2"
>
<Paperclip className="size-5 shrink-0" />
<TextShimmer className="text-xl" active={uploading}>
{item.fileName}
</TextShimmer>
<button
type="button"
onClick={() => {
void handleFileRemove(index);
}}
disabled={uploading}
>
<X className="size-5 shrink-0 text-destructive" aria-label="삭제" />
</button>
</div>
);
})}
</div>
) : null}
<label htmlFor={fileInputId} className="block">
<input
id={fileInputId}
type="file"
multiple
className="peer sr-only"
onChange={(e) => {
void handleFileUpload(e.target.files);
e.target.value = "";
}}
/>
<Button
type="button"
size="sm"
variant="outline"
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
asChild
>
<span>파일추가</span>
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<Label>필수첨부여부</Label>
<RadioGroup
@@ -142,7 +290,7 @@ function EsntlAtchFileFormDialog({
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting || isAnyUploading}>
저장
</Button>
</DialogFooter>
@@ -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<EsntlAtchFileMng | null>(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") {
+5 -1
View File
@@ -108,7 +108,11 @@ export interface EsntlAtchFileMngTreeResponseDto {
esntlAtchYn: string;
/** 필수첨부파일비고 */
esntlAtchFileRmrk: string | null;
/** 첨부파일 목록 (슬롯당 atchFileGroupId 1:N) */
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
/** 템플릿 샘플 파일 목록 (트리 조회 시 양식파일) */
templateAtchFileList?: AtflMngClientResponseDto[];
/** 첨부파일 목록 (문서 사용자 첨부) */
atchFileList?: AtflMngClientResponseDto[];
}