[as] feat: 보완요청 기능 구현

This commit is contained in:
2026-07-15 17:52:43 +09:00
parent 9e2da003f1
commit 70faadb27e
15 changed files with 735 additions and 406 deletions
+5 -1
View File
@@ -30,5 +30,9 @@ export { ContactInfoFormSection } from "./ui/contact-info-form-section";
export { FnrsSharFormSection } from "./ui/fnrs-shar-form-section";
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
export { SectionCollapsible } from "./ui/section-collapsible";
export {
SplmntDmndDialog,
type SplmntDmndDialogConfirmItem,
type SplmntDmndDialogConfirmPayload,
} from "./ui/splmnt-dmnd-dialog";
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
export { TextInputDialog } from "./ui/text-input-dialog";
@@ -0,0 +1,329 @@
"use client";
import { useEffect, useId, useState } from "react";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { attachService } from "@/entities/attach";
import { CodeSelect } from "@/entities/code";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
interface UploadedSplmntFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
interface SplmntDmndRow {
rowKey: string;
splmntArtclComCd: string;
splmntDmndCn: string;
splmntDmndAtchFileGroupId: string;
uploadedFile: UploadedSplmntFile | null;
}
interface SplmntDmndDialogConfirmItem {
splmntArtclComCd: string;
splmntDmndCn: string;
splmntDmndAtchFileGroupId?: string;
}
interface SplmntDmndDialogConfirmPayload {
items: SplmntDmndDialogConfirmItem[];
}
interface SplmntDmndDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
onConfirm: (payload: SplmntDmndDialogConfirmPayload) => void;
}
function createRowKey() {
return `splmnt-row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function createEmptyRow(): SplmntDmndRow {
return {
rowKey: createRowKey(),
splmntArtclComCd: "",
splmntDmndCn: "",
splmntDmndAtchFileGroupId: "",
uploadedFile: null,
};
}
function createUploadKey(rowKey: string, file: File) {
return `${SPLMNT_DMND_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
}
function getRowKeyFromFileKey(fileKey: string): string | null {
if (!fileKey.startsWith(SPLMNT_DMND_FILE_KEY_PREFIX)) return null;
const rest = fileKey.slice(SPLMNT_DMND_FILE_KEY_PREFIX.length);
const separatorIndex = rest.indexOf("::");
if (separatorIndex < 0) return null;
return rest.slice(0, separatorIndex);
}
function isRowsValid(rows: SplmntDmndRow[]) {
return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
}
function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) {
const formId = useId();
const [rows, setRows] = useState<SplmntDmndRow[]>([createEmptyRow()]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
const rowKey = getRowKeyFromFileKey(fileKey);
if (!rowKey) return;
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
return {
...row,
splmntDmndAtchFileGroupId: atflCd,
uploadedFile: { ...row.uploadedFile, atflCd, atflId },
};
}),
);
},
onError: (_error, _file, fileKey) => {
const rowKey = getRowKeyFromFileKey(fileKey);
if (!rowKey) return;
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
return { ...row, uploadedFile: null };
}),
);
},
});
useEffect(() => {
if (!open) return;
setRows([createEmptyRow()]);
}, [open]);
const isAnyUploading = rows.some(
(row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
);
const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
function updateRow(rowKey: string, patch: Partial<SplmntDmndRow>) {
setRows((prev) => prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
}
function handleAddRow() {
setRows((prev) => [...prev, createEmptyRow()]);
}
function handleRemoveRow(rowKey: string) {
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.rowKey !== rowKey)));
}
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;
updateRow(rowKey, {
uploadedFile: { fileName: file.name, fileKey },
});
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove(rowKey: string) {
const targetRow = rows.find((row) => row.rowKey === rowKey);
if (!targetRow?.uploadedFile) return;
const { fileKey, atflId } = targetRow.uploadedFile;
uploads[fileKey]?.abort();
if (atflId) {
try {
await attachService.deleteAttachmentByAtflId(atflId);
} catch (error) {
console.error("보완요청 첨부파일 삭제 실패:", error);
}
}
updateRow(rowKey, {
uploadedFile: null,
splmntDmndAtchFileGroupId: "",
});
}
function handleConfirm() {
if (!canSubmit) return;
onConfirm({
items: rows.map((row) => ({
splmntArtclComCd: row.splmntArtclComCd.trim(),
splmntDmndCn: row.splmntDmndCn.trim(),
splmntDmndAtchFileGroupId: row.splmntDmndAtchFileGroupId || undefined,
})),
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[100rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>보완요청</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h4 className="text-xl font-semibold">보완 내용</h4>
<Button type="button" size="sm" color="green" onClick={handleAddRow}>
항목 추가
</Button>
</div>
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="50%" />
<col width="20%" />
{rows.length > 1 ? <col width="10%" /> : null}
</colgroup>
<TableHeader>
<TableRow>
<TableHead>보완 항목</TableHead>
<TableHead>보완 의견</TableHead>
<TableHead>첨부파일</TableHead>
{rows.length > 1 ? <TableHead>관리</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const uploading = row.uploadedFile
? (isUploading[row.uploadedFile.fileKey] ?? false)
: false;
const fileInputId = `${formId}-${row.rowKey}-file`;
return (
<TableRow key={row.rowKey}>
<TableCell align="center" className="align-top">
<CodeSelect
upCd={SPLMNT_ARTCL_UP_CD}
value={row.splmntArtclComCd || undefined}
onValueChange={(value) =>
updateRow(row.rowKey, { splmntArtclComCd: value })
}
placeholder="보완항목 선택"
className="w-full min-w-[12rem]"
/>
</TableCell>
<TableCell className="align-top">
<Textarea
value={row.splmntDmndCn}
onChange={(event) =>
updateRow(row.rowKey, { splmntDmndCn: event.target.value })
}
placeholder="보완 의견을 입력하세요."
rows={4}
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">
<span className="text-xl">{row.uploadedFile.fileName}</span>
<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>
{rows.length > 1 ? (
<TableCell align="center" className="align-top">
<Button
type="button"
color="white"
size="sm"
onClick={() => handleRemoveRow(row.rowKey)}
>
삭제
</Button>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="button" onClick={handleConfirm} disabled={!canSubmit}>
보완요청
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { SplmntDmndDialog };
export type { SplmntDmndDialogConfirmItem, SplmntDmndDialogConfirmPayload };
@@ -1,77 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Textarea,
} from "@common/components";
interface TextInputDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
placeholder?: string;
defaultValue?: string;
isSubmitting?: boolean;
onConfirm: (text: string) => void;
}
function TextInputDialog({
open,
onOpenChange,
title,
placeholder,
defaultValue,
isSubmitting,
onConfirm,
}: TextInputDialogProps) {
const [value, setValue] = useState("");
useEffect(() => {
if (!open) return;
setValue(defaultValue ?? "");
}, [defaultValue, open]);
function handleConfirm() {
onConfirm(value.trim());
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[44rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Textarea
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={placeholder}
rows={6}
className="!text-xl"
/>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={isSubmitting || value.trim().length === 0}
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { TextInputDialog };