[as] feat: 보완요청 기능 구현
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user