[as] feat: 첨부파일 컴포넌트 생성 및 적용

This commit is contained in:
2026-09-11 17:23:45 +09:00
parent d1ccddc1d3
commit a7e0d8f826
10 changed files with 270 additions and 384 deletions
@@ -3,10 +3,8 @@
import { useEffect, useId, useRef, 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 { AttachmentFileField } from "@/shared/ui";
import { attachService } from "@/entities/attach";
import { CodeSelect } from "@/entities/code";
import type { EsntlAtchFileMng } from "@/entities/esntl";
@@ -217,57 +215,16 @@ function EsntlAtchFileFormDialog({
</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>
<AttachmentFileField
files={uploadedFiles.map((item) => ({
fileKey: item.fileKey,
fileName: item.fileName,
}))}
inputId={fileInputId}
isUploadingByKey={isUploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={(index) => void handleFileRemove(index)}
/>
</div>
<div className="space-y-2">
<Label>필수첨부여부</Label>
@@ -4,15 +4,10 @@ import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Paperclip, X } from "lucide-react";
import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import {
attachService,
parseFilenameFromResponse,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import { AttachmentFileField } from "@/shared/ui";
import { attachService, parseFilenameFromResponse } from "@/entities/attach";
import {
pwrkCnsltnDocCrtRsltSaveRequestSchema,
pwrkCnsltnDocCrtRsltService,
@@ -70,7 +65,6 @@ function PwrkCnsltnDocMngDialog({
}: PwrkCnsltnDocMngDialogProps) {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const downloadByAtflId = useDownloadAttachmentByAtflId();
const [atchFileGroupId, setAtchFileGroupId] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
@@ -237,11 +231,6 @@ function PwrkCnsltnDocMngDialog({
}
}
function handleAtchFileDownload(atflId?: string) {
if (!atflId) return;
downloadByAtflId.mutate(atflId);
}
const isAnyAtchUploading = uploadedFiles.some((item) => isUploading[item.fileKey]);
const canSave = !atrzSeComCdList.some((code) => APPROVER_ATRZ_SE_COM_CDS.has(code));
@@ -281,69 +270,21 @@ function PwrkCnsltnDocMngDialog({
{completedDraft ? (
<section className="space-y-2 rounded-md border border-border p-4">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">첨부파일</h3>
<label htmlFor="pwrk-cnsltn-doc-atch-file-input">
<input
type="file"
id="pwrk-cnsltn-doc-atch-file-input"
multiple
className="peer sr-only"
disabled={isAnyAtchUploading}
onChange={(event) => {
void handleAtchFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button
size="sm"
variant="outline"
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
disabled={isAnyAtchUploading}
asChild
>
<span>파일추가</span>
</Button>
</label>
</div>
{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" />
<button
type="button"
className="min-w-0 text-left disabled:cursor-default"
disabled={!item.atflId || downloadByAtflId.isPending}
onClick={() => handleAtchFileDownload(item.atflId)}
>
<TextShimmer className="text-xl" active={uploading}>
{item.fileName}
</TextShimmer>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
void handleAtchFileRemove(index);
}}
disabled={uploading}
>
<X className="size-5 shrink-0 text-destructive" aria-label="삭제" />
</button>
</div>
);
})}
</div>
) : (
<h3 className="text-xl font-semibold">첨부파일</h3>
{uploadedFiles.length === 0 ? (
<p className="text-lg text-muted-foreground">등록된 첨부파일이 없습니다.</p>
)}
) : null}
<AttachmentFileField
files={uploadedFiles.map((item) => ({
fileKey: item.fileKey,
fileName: item.fileName,
}))}
inputId="pwrk-cnsltn-doc-atch-file-input"
isUploadingByKey={isUploading}
addDisabled={isAnyAtchUploading}
onAdd={(files) => void handleAtchFileUpload(files)}
onRemove={(index) => void handleAtchFileRemove(index)}
/>
</section>
) : null}
@@ -6,13 +6,11 @@ import { Paperclip } from "lucide-react";
import { PROXY_HOST } from "@/shared/config";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
@@ -246,38 +244,20 @@ const TechReviewOpinionSection = forwardRef<
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
<AttachmentFileField
files={
uploadedFile
? [{ fileKey: uploadedFile.fileKey, fileName: uploadedFile.fileName }]
: []
}
inputId={`rvw-opnn-${reviewStep}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!uploadedFile}
addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={() => void handleFileRemove()}
/>
</TableCell>
</TableRow>
</TableHeader>
@@ -0,0 +1,62 @@
"use client";
import { FileAddButton } from "./file-add-button";
import { UploadedFileChip } from "./uploaded-file-chip";
interface AttachmentFileFieldItem {
fileKey: string;
fileName: string;
}
interface AttachmentFileFieldProps {
files: AttachmentFileFieldItem[];
inputId: string;
isUploadingByKey: Record<string, boolean>;
onAdd: (files: FileList | null) => void;
onRemove?: (index: number) => void;
accept?: string;
multiple?: boolean;
addDisabled?: boolean;
showAddButton?: boolean;
}
function AttachmentFileField({
files,
inputId,
isUploadingByKey,
onAdd,
onRemove,
accept,
multiple = true,
addDisabled = false,
showAddButton = true,
}: AttachmentFileFieldProps) {
return (
<div className="space-y-2">
{files.length > 0 ? (
<div className="flex flex-wrap gap-2">
{files.map((file, index) => (
<UploadedFileChip
key={file.fileKey}
fileName={file.fileName}
isUploading={isUploadingByKey[file.fileKey] ?? false}
onRemove={onRemove ? () => onRemove(index) : undefined}
/>
))}
</div>
) : null}
{showAddButton ? (
<FileAddButton
inputId={inputId}
accept={accept}
multiple={multiple}
disabled={addDisabled}
onFilesSelected={onAdd}
/>
) : null}
</div>
);
}
export { AttachmentFileField };
export type { AttachmentFileFieldItem, AttachmentFileFieldProps };
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { Button, cn } from "@common/components";
interface FileAddButtonProps {
inputId: string;
onFilesSelected: (files: FileList | null) => void;
accept?: string;
multiple?: boolean;
disabled?: boolean;
label?: string;
}
function FileAddButton({
inputId,
onFilesSelected,
accept,
multiple = true,
disabled = false,
label = "파일추가",
}: FileAddButtonProps) {
return (
<label htmlFor={inputId} className={cn("cursor-pointer", disabled && "pointer-events-none")}>
<input
id={inputId}
type="file"
multiple={multiple}
accept={accept}
disabled={disabled}
className="peer sr-only"
aria-label={label}
onChange={(event) => {
onFilesSelected(event.target.files);
event.target.value = "";
}}
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
className={cn(
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
disabled && "pointer-events-none opacity-50",
)}
asChild
>
<span>{label}</span>
</Button>
</label>
);
}
export { FileAddButton };
export type { FileAddButtonProps };
+6
View File
@@ -1,3 +1,7 @@
export type { AttachmentFileFieldItem, AttachmentFileFieldProps } from "./attachment-file-field";
export { AttachmentFileField } from "./attachment-file-field";
export type { FileAddButtonProps } from "./file-add-button";
export { FileAddButton } from "./file-add-button";
export { NoData } from "./no-data";
export { PageLayout } from "./page-layout";
export { SectionCollapsible } from "./section-collapsible";
@@ -7,3 +11,5 @@ export {
} from "./section-collapsible-group";
export type { TextShimmerProps } from "./text-shimmer";
export { TEXT_SHIMMER_CLASS, TextShimmer } from "./text-shimmer";
export type { UploadedFileChipProps } from "./uploaded-file-chip";
export { UploadedFileChip } from "./uploaded-file-chip";
@@ -0,0 +1,38 @@
"use client";
import { Paperclip, X } from "lucide-react";
import { TextShimmer } from "./text-shimmer";
interface UploadedFileChipProps {
fileName: string;
isUploading?: boolean;
onRemove?: () => void;
}
function UploadedFileChip({ fileName, isUploading = false, onRemove }: UploadedFileChipProps) {
return (
<div 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={isUploading}>
{fileName}
</TextShimmer>
{onRemove ? (
<button
type="button"
aria-label="삭제"
onClick={(event) => {
event.preventDefault();
onRemove();
}}
disabled={isUploading}
>
<X className="size-5 shrink-0 text-destructive" />
</button>
) : null}
</div>
);
}
export { UploadedFileChip };
export type { UploadedFileChipProps };
@@ -4,13 +4,17 @@ import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query";
import { Download, Paperclip, X } from "lucide-react";
import { Download } from "lucide-react";
import { ENDPOINT } from "@/shared/config";
import { useTus } from "@/shared/lib";
import type { AtflMngClientResponseDto, EsntlAtchFileMngTreeResponseDto } from "@/shared/model";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import {
AttachmentFileField,
FileAddButton,
SectionCollapsible,
UploadedFileChip,
} from "@/shared/ui";
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
@@ -423,64 +427,21 @@ function AttachmentsFormSection({
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
{uploadedFiles[file.esntlAtchFileId]?.length > 0 && (
<div className="flex flex-wrap gap-2">
{uploadedFiles[file.esntlAtchFileId].map((item, idx) => {
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.file?.name ?? item.fileName}
</TextShimmer>
<button
type="button"
onClick={(e) => {
e.preventDefault();
void removeEssentialFile(file.esntlAtchFileId, idx);
}}
disabled={uploading}
>
<X
className="size-5 shrink-0 text-destructive"
aria-label="삭제"
/>
</button>
</div>
);
})}
</div>
)}
<div>
<label htmlFor={`${file.esntlAtchFileId}-file-input`}>
<input
type="file"
id={`${file.esntlAtchFileId}-file-input`}
multiple
className="peer sr-only"
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onChange={(e) => {
void handleEssentialFileUpload(
file.esntlAtchFileId,
e.target.files,
);
e.target.value = "";
}}
/>
<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>
<AttachmentFileField
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
fileKey: item.fileKey,
fileName: item.file?.name ?? item.fileName,
}))}
inputId={`${file.esntlAtchFileId}-file-input`}
isUploadingByKey={isUploading}
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onAdd={(fileList) =>
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
}
onRemove={(index) =>
void removeEssentialFile(file.esntlAtchFileId, index)
}
/>
</TableCell>
</TableRow>
);
@@ -495,55 +456,22 @@ function AttachmentsFormSection({
<div className="px-2">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-xl font-semibold">기타 첨부파일</h3>
<label htmlFor="etc-file-input">
<input
type="file"
id="etc-file-input"
multiple
className="peer sr-only"
onChange={(e) => {
void handleEtcFileUpload(e.target.files);
e.target.value = "";
}}
/>
<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>
<FileAddButton
inputId="etc-file-input"
onFilesSelected={(files) => void handleEtcFileUpload(files)}
/>
</div>
<div className="rounded border p-4">
{etcFiles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{etcFiles.map((item, idx) => {
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.file?.name ?? item.fileName}
</TextShimmer>
<button
type="button"
onClick={(e) => {
e.preventDefault();
void removeEtcFile(idx);
}}
disabled={uploading}
>
<X className="size-5 shrink-0 text-destructive" aria-label="삭제" />
</button>
</div>
);
})}
{etcFiles.map((item, idx) => (
<UploadedFileChip
key={item.fileKey}
fileName={item.file?.name ?? item.fileName}
isUploading={isUploading[item.fileKey] ?? false}
onRemove={() => void removeEtcFile(idx)}
/>
))}
</div>
) : (
<div className="flex items-center justify-center py-6 text-center text-xl text-muted-foreground">
@@ -2,10 +2,8 @@
import { useEffect, useId, useState } from "react";
import { Paperclip, X } from "lucide-react";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { AttachmentFileField } from "@/shared/ui";
import { attachService } from "@/entities/attach";
import { CodeSelect } from "@/entities/code";
import {
@@ -287,57 +285,17 @@ function SplmntDmndDialog({
/>
</TableCell>
<TableCell className="space-y-2 align-top">
{row.uploadedFiles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{row.uploadedFiles.map((file, fileIndex) => {
const uploading = isUploading[file.fileKey] ?? false;
return (
<div
key={file.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}>
{file.fileName}
</TextShimmer>
<button
type="button"
disabled={uploading}
onClick={() => void handleFileRemove(row.rowKey, fileIndex)}
>
<X
className="size-5 shrink-0 text-destructive"
aria-label="삭제"
/>
</button>
</div>
);
})}
</div>
) : null}
<label htmlFor={fileInputId} className="cursor-pointer">
<input
id={fileInputId}
type="file"
multiple
disabled={isRowUploading}
className="peer sr-only"
onChange={(event) => {
void handleFileUpload(row.rowKey, event.target.files);
event.target.value = "";
}}
/>
<Button
size="sm"
variant="outline"
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
disabled={isRowUploading}
asChild
>
<span>파일추가</span>
</Button>
</label>
<AttachmentFileField
files={row.uploadedFiles.map((file) => ({
fileKey: file.fileKey,
fileName: file.fileName,
}))}
inputId={fileInputId}
isUploadingByKey={isUploading}
addDisabled={isRowUploading}
onAdd={(fileList) => void handleFileUpload(row.rowKey, fileList)}
onRemove={(fileIndex) => void handleFileRemove(row.rowKey, fileIndex)}
/>
</TableCell>
{rows.length > 1 ? (
<TableCell align="center" className="align-top">
@@ -3,15 +3,13 @@
import { useEffect, useId, useRef, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { Paperclip, X } from "lucide-react";
import { Paperclip } from "lucide-react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { attachService } from "@/entities/attach";
import {
Badge,
Button,
Table,
TableBody,
TableCell,
@@ -303,63 +301,26 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
)}
</TableCell>
<TableCell className="space-y-2 align-top">
{rowFiles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{rowFiles.map((file, fileIndex) => {
const uploading = isUploading[file.fileKey] ?? false;
return (
<div
key={file.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}>
{file.fileName}
</TextShimmer>
{!isRowReadOnly ? (
<button
type="button"
disabled={uploading}
onClick={() => void handleFileRemove(index, fileIndex)}
>
<X
className="size-5 shrink-0 text-destructive"
aria-label="삭제"
/>
</button>
) : null}
</div>
);
})}
</div>
) : null}
{!isRowReadOnly ? (
<label htmlFor={fileInputId} className="block cursor-pointer">
<input
id={fileInputId}
type="file"
multiple
disabled={isRowUploading}
className="peer sr-only"
onChange={(event) => {
void handleFileUpload(index, event.target.files);
event.target.value = "";
}}
/>
<Button
size="sm"
variant="outline"
className="peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
disabled={isRowUploading}
asChild
>
<span>파일추가</span>
</Button>
</label>
) : rowFiles.length === 0 ? (
{isRowReadOnly && rowFiles.length === 0 ? (
<span>-</span>
) : null}
) : (
<AttachmentFileField
files={rowFiles.map((file) => ({
fileKey: file.fileKey,
fileName: file.fileName,
}))}
inputId={fileInputId}
isUploadingByKey={isUploading}
addDisabled={isRowUploading}
showAddButton={!isRowReadOnly}
onAdd={(fileList) => void handleFileUpload(index, fileList)}
onRemove={
isRowReadOnly
? undefined
: (fileIndex) => void handleFileRemove(index, fileIndex)
}
/>
)}
</TableCell>
<TableCell align="center" className="align-top">
<Badge size="lg" color={item.cmptnYn === "Y" ? "green" : "gray"}>