[as] 필수펌부파일관리 내 양식파일 컬럼 추가
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -108,7 +108,11 @@ export interface EsntlAtchFileMngTreeResponseDto {
|
||||
esntlAtchYn: string;
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk: string | null;
|
||||
/** 첨부파일 목록 (슬롯당 atchFileGroupId 1:N) */
|
||||
/** 첨부파일그룹아이디 (양식파일 그룹) */
|
||||
atchFileGroupId?: string;
|
||||
/** 템플릿 샘플 파일 목록 (트리 조회 시 양식파일) */
|
||||
templateAtchFileList?: AtflMngClientResponseDto[];
|
||||
/** 첨부파일 목록 (문서 사용자 첨부) */
|
||||
atchFileList?: AtflMngClientResponseDto[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user