[as] feat: 실무협의서 관리 다이얼로그 내 첨부파일 섹션 추가

This commit is contained in:
2026-09-10 17:50:56 +09:00
parent 9f7f1db9b8
commit 41ad33310a
6 changed files with 255 additions and 23 deletions
@@ -5,6 +5,7 @@ import type {
PwrkCnsltnDocCrtRsltResponseDto,
PwrkCnsltnDocCrtRsltService,
} from "../model/pwrk-cnsltn-doc-crt-rslt-interface";
import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "../model/pwrk-cnsltn-doc-crt-rslt-payload-schema";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT;
@@ -35,7 +36,22 @@ async function downloadLatest(crtDmndUuid: string) {
});
}
async function saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
async function saveMultipart(
saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto,
file?: Blob | File,
fileName?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const formData = new FormData();
formData.append("crtDmndUuid", saveDto.crtDmndUuid);
formData.append("pwrkCnsltnDocNo", saveDto.pwrkCnsltnDocNo);
if (saveDto.atchFileGroupId) {
formData.append("atchFileGroupId", saveDto.atchFileGroupId);
}
if (file) {
const resolvedFileName = fileName ?? (file instanceof File ? file.name : "document.hwpx");
formData.append("file", file, resolvedFileName);
}
const response = await client.post<PwrkCnsltnDocCrtRsltResponseDto>(BASE, formData, {
headers: { "Content-Type": undefined },
});
@@ -1,5 +1,7 @@
import type { AxiosResponse } from "axios";
import type { PwrkCnsltnDocCrtRsltSaveRequestDto } from "./pwrk-cnsltn-doc-crt-rslt-payload-schema";
export interface PwrkCnsltnDocCrtRsltResponseDto {
crtDmndUuid: string;
verSn: number;
@@ -21,7 +23,11 @@ export interface PwrkCnsltnDocCrtRsltService {
openWebHwpLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
download(crtDmndUuid: string, verSn: number): Promise<AxiosResponse<Blob>>;
downloadLatest(crtDmndUuid: string): Promise<AxiosResponse<Blob>>;
saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
saveMultipart(
saveDto: PwrkCnsltnDocCrtRsltSaveRequestDto,
file?: Blob | File,
fileName?: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
updateMultipart(
crtDmndUuid: string,
verSn: number,
@@ -1,7 +1,10 @@
import { z } from "zod";
/** 백엔드 PwrkCnsltnDocCrtRsltSaveRequestDto (orgnlOtptDocCn은 multipart file로 전송) */
export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
crtDmndUuid: z.string().uuid(),
pwrkCnsltnDocNo: z.string().min(1).max(100),
atchFileGroupId: z.string().max(30).optional(),
});
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
@@ -24,6 +24,7 @@ export interface PwrkCnsltnMngDetResponseDto {
upDocSeComCd?: string;
prgrsSttsComCd?: string;
bizNm?: string;
atchFileGroupId?: string;
crtDmndList?: PwrkCnsltnDocCrtDmndResponseDto[];
crtRsltList?: PwrkCnsltnDocCrtRsltResponseDto[];
}
@@ -1,12 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Paperclip, X } from "lucide-react";
import { useAlertStore } from "@/shared/lib";
import { parseFilenameFromResponse } from "@/entities/attach";
import { pwrkCnsltnDocCrtRsltService } from "@/entities/pwrk-cnsltn-doc-crt-rslt";
import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import {
attachService,
parseFilenameFromResponse,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import {
pwrkCnsltnDocCrtRsltSaveRequestSchema,
pwrkCnsltnDocCrtRsltService,
} from "@/entities/pwrk-cnsltn-doc-crt-rslt";
import {
findCompletedPwrkDraft,
pwrkCnsltnMngKeys,
@@ -30,15 +40,32 @@ const HangulGiangi = dynamic(
},
);
const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
interface PwrkCnsltnDocMngDialogProps {
open: boolean;
fnrsCnsltnDocNo: string;
onClose: () => void;
}
interface UploadedAtchFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
function createPwrkAtchUploadKey(file: File) {
return `${PWRK_ATCH_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDocMngDialogProps) {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const downloadByAtflId = useDownloadAttachmentByAtflId();
const [atchFileGroupId, setAtchFileGroupId] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
const {
data: pwrkCnsltnData,
isLoading: isPwrkCnsltnLoading,
@@ -46,6 +73,7 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
} = usePwrkCnsltnMngByUpDocNo(fnrsCnsltnDocNo, open);
const completedDraft = findCompletedPwrkDraft(pwrkCnsltnData);
const crtDmndUuid = completedDraft?.crtDmndUuid;
const {
data: documentBlob,
isLoading: isDocumentLoading,
@@ -56,32 +84,84 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
select: (response) => response.data,
enabled: open && Boolean(crtDmndUuid),
});
const documentFileName = `실무협의서_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`;
const isLoading = isPwrkCnsltnLoading || isDocumentLoading;
const isError = isPwrkCnsltnError || isDocumentError;
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setAtchFileGroupId(atflCd);
setUploadedFiles((prev) =>
prev.map((item) => (item.fileKey === fileKey ? { ...item, atflCd, atflId } : item)),
);
},
onError: (_error, _file, fileKey) => {
setUploadedFiles((prev) => prev.filter((item) => item.fileKey !== fileKey));
},
});
useEffect(() => {
if (!open) {
setAtchFileGroupId("");
setUploadedFiles([]);
return;
}
const initialGroupId = pwrkCnsltnData?.atchFileGroupId ?? "";
setAtchFileGroupId(initialGroupId);
if (!initialGroupId) {
setUploadedFiles([]);
return;
}
let cancelled = false;
void attachService.findByAtflCd(initialGroupId).then((files) => {
if (cancelled) return;
setUploadedFiles(
files.map((file) => ({
fileName: file.orgnlFileNm,
fileKey: `${PWRK_ATCH_FILE_KEY_PREFIX}${file.atflId}`,
atflId: file.atflId,
atflCd: file.atflCd,
})),
);
});
return () => {
cancelled = true;
};
}, [open, pwrkCnsltnData?.atchFileGroupId]);
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
}
async function handleSaveDocument(file: Blob) {
const crtRslt = completedDraft?.crtRslt;
if (!completedDraft || crtRslt?.verSn == null) {
throw new Error("저장할 실무협의서 초안이 없습니다.");
}
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다.");
const formData = new FormData();
formData.append("file", file, documentFileName);
await pwrkCnsltnDocCrtRsltService.updateMultipart(
completedDraft.crtDmndUuid,
crtRslt.verSn,
formData,
);
await queryClient.invalidateQueries({
queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo),
const saveDto = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
crtDmndUuid: completedDraft.crtDmndUuid,
pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo,
atchFileGroupId: atchFileGroupId || undefined,
});
alert("실무협의서를 저장했습니다.");
const savedDocument = await pwrkCnsltnDocCrtRsltService.saveMultipart(
saveDto,
file,
documentFileName,
);
await Promise.all([
queryClient.invalidateQueries({
queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo),
}),
queryClient.invalidateQueries({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", completedDraft.crtDmndUuid],
}),
]);
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
}
async function handleDownload() {
@@ -98,6 +178,64 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
window.URL.revokeObjectURL(objectUrl);
}
async function handleAtchFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
let groupId = atchFileGroupId;
for (const file of fileArray) {
const fileKey = createPwrkAtchUploadKey(file);
setUploadedFiles((prev) => [...prev, { fileName: file.name, fileKey }]);
try {
const atflCd = await uploadFileAsync(file, {
fileKey,
metadata: groupId ? { atflCd: groupId } : undefined,
});
if (!groupId && atflCd) {
groupId = atflCd;
setAtchFileGroupId(atflCd);
}
} catch {
// onError callback handles cleanup
}
}
}
async function handleAtchFileRemove(fileIndex: number) {
const fileToRemove = uploadedFiles[fileIndex];
if (!fileToRemove) return;
uploads[fileToRemove.fileKey]?.abort();
if (fileToRemove.atflId) {
try {
await attachService.deleteAttachmentByAtflId(fileToRemove.atflId);
} catch (error) {
console.error("실무협의서 첨부파일 삭제 실패:", error);
alert("첨부파일 삭제에 실패했습니다.");
return;
}
}
const nextFiles = uploadedFiles.filter((_, index) => index !== fileIndex);
setUploadedFiles(nextFiles);
if (nextFiles.length === 0) {
setAtchFileGroupId("");
}
}
function handleAtchFileDownload(atflId?: string) {
if (!atflId) return;
downloadByAtflId.mutate(atflId);
}
const isAnyAtchUploading = uploadedFiles.some((item) => isUploading[item.fileKey]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
@@ -110,12 +248,12 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
{isError ? (
<p className="py-16 text-center text-sm text-destructive">
<p className="py-16 text-center text-lg text-destructive">
실무협의서 정보를 불러오지 못했습니다.
</p>
) : null}
{!isLoading && !isError && !completedDraft ? (
<p className="py-16 text-center text-sm text-muted-foreground">
<p className="py-16 text-center text-lg text-muted-foreground">
생성 완료된 실무협의서 초안이 없습니다.
</p>
) : null}
@@ -131,6 +269,74 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
) : null
) : null}
{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>
) : (
<p className="text-lg text-muted-foreground">등록된 첨부파일이 없습니다.</p>
)}
</section>
) : null}
<DialogFooter>
<Button type="button" color="white" onClick={onClose}>
닫기
@@ -194,7 +194,7 @@ function HangulGiangi({
name={HWP_CTRL_IFRAME_NAME}
title="웹한글 기안기"
src={iframeSrc}
className="fullscreen:h-screen fullscreen:min-h-0 fullscreen:w-screen h-[70vh] min-h-[40rem] w-full border-0 bg-background"
className="fullscreen:h-screen fullscreen:min-h-0 fullscreen:w-screen h-[64vh] min-h-[40rem] w-full border-0 bg-background"
/>
</div>
);