[as] feat: 보완요청 파일 첨부 로직 수정
This commit is contained in:
@@ -3,7 +3,13 @@ import { AxiosResponse } from "axios";
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { AttachService } from "../model/attach-interface";
|
||||
import type { Attach, AttachService } from "../model/attach-interface";
|
||||
|
||||
async function findByAtflCd(atflCd: string): Promise<Attach[]> {
|
||||
if (!atflCd) return [];
|
||||
const response = await client.get<Attach[]>(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteAttachmentByAtflId(atflId: string): Promise<void> {
|
||||
if (!atflId) return;
|
||||
@@ -23,6 +29,7 @@ async function downloadAttachmentByAtflId(atflId: string): Promise<AxiosResponse
|
||||
}
|
||||
|
||||
export const attachService = {
|
||||
findByAtflCd,
|
||||
deleteAttachmentByAtflId,
|
||||
downloadAttachmentByAtflCd,
|
||||
downloadAttachmentByAtflId,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Attach {
|
||||
}
|
||||
|
||||
export interface AttachService {
|
||||
findByAtflCd: (atflCd: string) => Promise<Attach[]>;
|
||||
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
|
||||
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
|
||||
downloadAttachmentByAtflId: (atflId: string) => Promise<AxiosResponse>;
|
||||
|
||||
@@ -3,9 +3,9 @@ const AI_PENDING_STATUS_CODES = ["FV", "AC", "AV"] as const;
|
||||
type AiPendingStatusCode = (typeof AI_PENDING_STATUS_CODES)[number];
|
||||
|
||||
const AI_PENDING_MESSAGES: Record<AiPendingStatusCode, string> = {
|
||||
FV: "파일 검증이 진행 중입니다",
|
||||
AC: "실무협의서 초안 생성이 진행 중입니다",
|
||||
AV: "금액 검증이 진행 중입니다",
|
||||
FV: "AI 파일 검증이 진행 중입니다",
|
||||
AC: "AI 실무협의서 초안 생성이 진행 중입니다",
|
||||
AV: "AI 금액 검증이 진행 중입니다",
|
||||
};
|
||||
|
||||
function isAiPendingStatus(prgrsSttsComCd: string): boolean {
|
||||
|
||||
@@ -322,7 +322,7 @@ function FloatingActionButtons({
|
||||
dmndTtl: data.detDto?.bizNm ?? "",
|
||||
dmndCn: data.detDto?.bizCn ?? "",
|
||||
elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1,
|
||||
docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd,
|
||||
docPrgrsSttsComCd: "AC",
|
||||
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
||||
};
|
||||
await executeAction({
|
||||
|
||||
@@ -88,7 +88,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
|
||||
function buildFormPayload(
|
||||
data: FnrsCnsltnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST" | "TE",
|
||||
prgrsSttsComCd: string,
|
||||
): FnrsCnsltnApplySaveRequestDto | undefined {
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
@@ -123,7 +123,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
|
||||
...item,
|
||||
...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
|
||||
...(prgrsSttsComCd !== "SV" && prgrsSttsComCd !== "ST" && { cmptnYn: "Y" }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -139,12 +139,13 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
|
||||
}
|
||||
|
||||
async function submitSupplementForm(data: FnrsCnsltnRegistFormData): Promise<string | undefined> {
|
||||
const beforePrgrsSttsComCd = initialData?.detDto?.beforePrgrsSttsComCd;
|
||||
if (!beforePrgrsSttsComCd) return undefined;
|
||||
|
||||
const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) return undefined;
|
||||
|
||||
const payload = buildFormPayload(data, "TE");
|
||||
if (!payload) return undefined;
|
||||
|
||||
const payload = buildFormPayload(data, beforePrgrsSttsComCd);
|
||||
if (!docNo || !payload) return undefined;
|
||||
debugger;
|
||||
return fnrsCnsltnMngService.submitSupplement(docNo, payload);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,9 +102,15 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
||||
<div className="sticky top-[6rem] z-30 flex items-center gap-4 bg-container px-4 py-2">
|
||||
<SectionCollapsibleToggle />
|
||||
{isAiPending && (
|
||||
<TextShimmer active className="text-xl">
|
||||
{getAiPendingMessage(detDto.prgrsSttsComCd || "")}
|
||||
</TextShimmer>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex size-3 rounded-full bg-primary"></span>
|
||||
</span>
|
||||
<TextShimmer active className="text-xl">
|
||||
{getAiPendingMessage(detDto.prgrsSttsComCd || "")}
|
||||
</TextShimmer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -2,6 +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 { attachService } from "@/entities/attach";
|
||||
@@ -36,7 +38,7 @@ interface SplmntDmndRow {
|
||||
splmntArtclComCd: string;
|
||||
splmntDmndCn: string;
|
||||
splmntDmndAtchFileGroupId: string;
|
||||
uploadedFile: UploadedSplmntFile | null;
|
||||
uploadedFiles: UploadedSplmntFile[];
|
||||
}
|
||||
|
||||
interface SplmntDmndDialogConfirmItem {
|
||||
@@ -67,7 +69,7 @@ function createEmptyRow(): SplmntDmndRow {
|
||||
splmntArtclComCd: "",
|
||||
splmntDmndCn: "",
|
||||
splmntDmndAtchFileGroupId: "",
|
||||
uploadedFile: null,
|
||||
uploadedFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,11 +107,13 @@ function SplmntDmndDialog({
|
||||
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
|
||||
if (row.rowKey !== rowKey) return row;
|
||||
return {
|
||||
...row,
|
||||
splmntDmndAtchFileGroupId: atflCd,
|
||||
uploadedFile: { ...row.uploadedFile, atflCd, atflId },
|
||||
uploadedFiles: row.uploadedFiles.map((file) =>
|
||||
file.fileKey === fileKey ? { ...file, atflCd, atflId } : file,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -120,8 +124,11 @@ function SplmntDmndDialog({
|
||||
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
|
||||
return { ...row, uploadedFile: null };
|
||||
if (row.rowKey !== rowKey) return row;
|
||||
return {
|
||||
...row,
|
||||
uploadedFiles: row.uploadedFiles.filter((file) => file.fileKey !== fileKey),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -132,8 +139,8 @@ function SplmntDmndDialog({
|
||||
setRows([createEmptyRow()]);
|
||||
}, [open]);
|
||||
|
||||
const isAnyUploading = rows.some(
|
||||
(row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
|
||||
const isAnyUploading = rows.some((row) =>
|
||||
row.uploadedFiles.some((file) => isUploading[file.fileKey] ?? false),
|
||||
);
|
||||
const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
|
||||
|
||||
@@ -152,30 +159,42 @@ function SplmntDmndDialog({
|
||||
async function handleFileUpload(rowKey: string, files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
const targetRow = rows.find((row) => row.rowKey === rowKey);
|
||||
const existingAtflCd = targetRow?.splmntDmndAtchFileGroupId;
|
||||
let existingAtflCd = targetRow?.splmntDmndAtchFileGroupId ?? "";
|
||||
|
||||
updateRow(rowKey, {
|
||||
uploadedFile: { fileName: file.name, fileKey },
|
||||
});
|
||||
for (const file of Array.from(files)) {
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
setRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.rowKey === rowKey
|
||||
? {
|
||||
...row,
|
||||
uploadedFiles: [...row.uploadedFiles, { fileName: file.name, fileKey }],
|
||||
}
|
||||
: row,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const atflCd = await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
|
||||
if (!existingAtflCd) existingAtflCd = atflCd;
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove(rowKey: string) {
|
||||
async function handleFileRemove(rowKey: string, fileIndex: number) {
|
||||
const targetRow = rows.find((row) => row.rowKey === rowKey);
|
||||
if (!targetRow?.uploadedFile) return;
|
||||
const targetFile = targetRow?.uploadedFiles[fileIndex];
|
||||
if (!targetRow || !targetFile) return;
|
||||
|
||||
const { fileKey, atflId } = targetRow.uploadedFile;
|
||||
const { fileKey, atflId } = targetFile;
|
||||
uploads[fileKey]?.abort();
|
||||
|
||||
if (atflId) {
|
||||
@@ -186,9 +205,10 @@ function SplmntDmndDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const nextFiles = targetRow.uploadedFiles.filter((_, index) => index !== fileIndex);
|
||||
updateRow(rowKey, {
|
||||
uploadedFile: null,
|
||||
splmntDmndAtchFileGroupId: "",
|
||||
uploadedFiles: nextFiles,
|
||||
splmntDmndAtchFileGroupId: nextFiles.length > 0 ? targetRow.splmntDmndAtchFileGroupId : "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,9 +257,9 @@ function SplmntDmndDialog({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const uploading = row.uploadedFile
|
||||
? (isUploading[row.uploadedFile.fileKey] ?? false)
|
||||
: false;
|
||||
const isRowUploading = row.uploadedFiles.some(
|
||||
(file) => isUploading[file.fileKey] ?? false,
|
||||
);
|
||||
const fileInputId = `${formId}-${row.rowKey}-file`;
|
||||
|
||||
return (
|
||||
@@ -266,40 +286,58 @@ function SplmntDmndDialog({
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.uploadedFile ? (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{row.uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 !bg-destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(row.rowKey)}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={uploading} asChild>
|
||||
<label htmlFor={fileInputId} className="cursor-pointer">
|
||||
파일추가
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(row.rowKey, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</TableCell>
|
||||
{rows.length > 1 ? (
|
||||
<TableCell align="center" className="align-top">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { Paperclip, X } from "lucide-react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -42,8 +42,6 @@ interface SplmntDmndFormItem {
|
||||
splmntPicNm?: string;
|
||||
splmntCmptnDt?: string;
|
||||
splmntAtchFileGroupId?: string;
|
||||
splmntAtchFileNm?: string;
|
||||
splmntAtchFileAtflId?: string;
|
||||
cmptnYn?: string;
|
||||
}
|
||||
|
||||
@@ -75,9 +73,10 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
const formId = useId();
|
||||
const { control, setValue, getValues } = useFormContext<SplmntDmndFormData>();
|
||||
const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedSplmntFile | null>>({});
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedSplmntFile[]>>({});
|
||||
const initialFilesMappedRef = useRef(false);
|
||||
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
|
||||
const isMountedRef = useRef(true);
|
||||
const atflCdByRowRef = useRef<Record<string, string>>({});
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
@@ -86,55 +85,93 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
const current = prev[rowKey] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[rowKey]: { ...current, atflCd, atflId },
|
||||
[rowKey]: current.map((item) =>
|
||||
item.fileKey === fileKey ? { ...item, atflCd, atflId } : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
atflCdByRowRef.current[rowKey] = atflCd;
|
||||
|
||||
const list = getValues("splmntDmndList") ?? [];
|
||||
const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
|
||||
if (index < 0) return;
|
||||
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
return { ...prev, [rowKey]: null };
|
||||
const nextFiles = (prev[rowKey] ?? []).filter((item) => item.fileKey !== fileKey);
|
||||
return { ...prev, [rowKey]: nextFiles };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFilesMappedRef.current) return;
|
||||
if (splmntDmndList.length === 0) return;
|
||||
initialFilesMappedRef.current = true;
|
||||
|
||||
const rowsWithFiles = splmntDmndList.flatMap((item, index) => {
|
||||
if (!item.splmntAtchFileGroupId) return [];
|
||||
|
||||
const initial: Record<string, UploadedSplmntFile | null> = {};
|
||||
for (let index = 0; index < splmntDmndList.length; index += 1) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
|
||||
initial[rowKey] = {
|
||||
fileName: item.splmntAtchFileNm,
|
||||
fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
|
||||
atflCd: item.splmntAtchFileGroupId,
|
||||
atflId: item.splmntAtchFileAtflId,
|
||||
};
|
||||
} else {
|
||||
initial[rowKey] = null;
|
||||
}
|
||||
atflCdByRowRef.current[rowKey] = item.splmntAtchFileGroupId;
|
||||
return [{ rowKey, atflCd: item.splmntAtchFileGroupId }];
|
||||
});
|
||||
|
||||
async function mapInitialFiles() {
|
||||
const results = await Promise.all(
|
||||
rowsWithFiles.map(async ({ rowKey, atflCd }) => {
|
||||
try {
|
||||
const files = await attachService.findByAtflCd(atflCd);
|
||||
return {
|
||||
rowKey,
|
||||
files: files.map((file) => ({
|
||||
fileName: file.orgnlFileNm,
|
||||
fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::server::${file.atflId}`,
|
||||
atflCd: file.atflCd,
|
||||
atflId: file.atflId,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("보완완료 첨부파일 조회 실패:", error);
|
||||
return { rowKey, files: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
for (const { rowKey, files } of results) {
|
||||
const localFiles = prev[rowKey] ?? [];
|
||||
const serverAtflIds = new Set(files.map((file) => file.atflId));
|
||||
next[rowKey] = [
|
||||
...files,
|
||||
...localFiles.filter((file) => !file.atflId || !serverAtflIds.has(file.atflId)),
|
||||
];
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setUploadedFiles(initial);
|
||||
initialFilesMappedRef.current = true;
|
||||
void mapInitialFiles();
|
||||
}, [splmntDmndList]);
|
||||
|
||||
if (splmntDmndList.length === 0) return null;
|
||||
@@ -144,30 +181,37 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const file = files[0];
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
const existingAtflCd = item.splmntAtchFileGroupId;
|
||||
let existingAtflCd = atflCdByRowRef.current[rowKey] || item.splmntAtchFileGroupId || "";
|
||||
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: { fileName: file.name, fileKey },
|
||||
}));
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
|
||||
for (const file of Array.from(files)) {
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: [...(prev[rowKey] ?? []), { fileName: file.name, fileKey }],
|
||||
}));
|
||||
|
||||
try {
|
||||
const atflCd = await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
|
||||
if (!existingAtflCd) {
|
||||
existingAtflCd = atflCd;
|
||||
atflCdByRowRef.current[rowKey] = atflCd;
|
||||
}
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove(index: number) {
|
||||
async function handleFileRemove(index: number, fileIndex: number) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey];
|
||||
const currentFiles = uploadedFiles[rowKey] ?? [];
|
||||
const uploaded = currentFiles[fileIndex];
|
||||
if (!uploaded) return;
|
||||
|
||||
const { fileKey, atflId } = uploaded;
|
||||
@@ -181,10 +225,13 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
|
||||
const nextFiles = currentFiles.filter((_, currentIndex) => currentIndex !== fileIndex);
|
||||
setUploadedFiles((prev) => ({ ...prev, [rowKey]: nextFiles }));
|
||||
|
||||
if (nextFiles.length > 0) return;
|
||||
|
||||
delete atflCdByRowRef.current[rowKey];
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -212,8 +259,8 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
<TableBody>
|
||||
{splmntDmndList.map((item, index) => {
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey] ?? null;
|
||||
const uploading = uploaded ? (isUploading[uploaded.fileKey] ?? false) : false;
|
||||
const rowFiles = uploadedFiles[rowKey] ?? [];
|
||||
const isRowUploading = rowFiles.some((file) => isUploading[file.fileKey] ?? false);
|
||||
const fileInputId = `${formId}-${rowKey}-file`;
|
||||
const isRowReadOnly = readOnly || item.cmptnYn === "Y";
|
||||
|
||||
@@ -229,13 +276,10 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
|
||||
{item.splmntDmndAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
|
||||
>
|
||||
<span className="inline-flex items-start gap-1">
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{item.splmntDmndAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
{item.splmntDmndAtchFileNm || "첨부파일"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -258,54 +302,64 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{uploaded ? (
|
||||
<div className="flex items-stretch gap-2">
|
||||
{uploaded.atflCd || item.splmntAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() =>
|
||||
downloadAttachment((uploaded.atflCd || item.splmntAtchFileGroupId)!)
|
||||
}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{uploaded.fileName}
|
||||
</a>
|
||||
) : (
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploaded.fileName}
|
||||
</TextShimmer>
|
||||
)}
|
||||
{!isRowReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 !bg-destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(index)}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
) : null}
|
||||
<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>
|
||||
) : isRowReadOnly ? (
|
||||
) : 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 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={uploading} asChild>
|
||||
<label htmlFor={fileInputId} className="cursor-pointer">
|
||||
파일추가
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(index, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="align-top">
|
||||
<Badge size="lg" color={item.cmptnYn === "Y" ? "green" : "gray"}>
|
||||
|
||||
Reference in New Issue
Block a user