Merge remote-tracking branch 'origin/iipbs/as/dev'
This commit is contained in:
+17
-1
@@ -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 },
|
||||
});
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+3
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user