[as] feat: 기안기 저장 코드 수정

This commit is contained in:
2026-09-10 14:50:09 +09:00
parent 0f27c1e5ce
commit 9f7f1db9b8
9 changed files with 4464 additions and 126 deletions
@@ -40,6 +40,7 @@ v1.0은 공단 접수자의 **자료검증 → 초안생성 → 웹기안기**
| [ref-screen-buttons.md](./ref-screen-buttons.md) | 38행 화면·역할·버튼 매트릭스, 버튼 가시성 | `button-config.ts` 개편 |
| [ref-endpoint.md](./ref-endpoint.md) | 버튼→API 총괄, 단계별 입출력, FE 호출 금지 API, Gap Analysis | 엔티티·service 매핑 |
| [ref-frontend-entities.md](./ref-frontend-entities.md) | ENDPOINT, 슬라이스, Service 시그니처, 폴링, 웹기안기, DTO | 엔티티 레이어 구현 |
| [ref-webhwp-api.md](./ref-webhwp-api.md) | 웹한글기안기 v3 `HwpCtrl` Properties/Methods, Open/SaveAs | 기안기 위젯 연동 |
---
File diff suppressed because it is too large Load Diff
+4
View File
@@ -40,6 +40,10 @@ const nextConfig: NextConfig = {
source: `/webhwpctrl/${prefix}/:path*`,
destination: `${webhwpCtrlBase}/${prefix}/:path*`,
})),
{
source: "/webhwpctrl/file/:path*",
destination: `${webhwpCtrlBase}/file/:path*`,
},
],
fallback: [],
};
@@ -42,10 +42,24 @@ async function saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltRe
return response.data;
}
async function updateMultipart(
crtDmndUuid: string,
verSn: number,
formData: FormData,
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const response = await client.put<PwrkCnsltnDocCrtRsltResponseDto>(
`${BASE}/${crtDmndUuid}/${verSn}`,
formData,
{ headers: { "Content-Type": undefined } },
);
return response.data;
}
export const pwrkCnsltnDocCrtRsltService = {
findLatestByCrtDmndUuid,
openWebHwpLatest,
download,
downloadLatest,
saveMultipart,
updateMultipart,
} satisfies PwrkCnsltnDocCrtRsltService;
@@ -2,6 +2,7 @@ export { pwrkCnsltnDocCrtRsltService } from "./api/pwrk-cnsltn-doc-crt-rslt-serv
export type {
PwrkCnsltnDocCrtRsltResponseDto,
PwrkCnsltnDocCrtRsltService,
PwrkCnsltnDocCrtRsltUpdateRequestDto,
} from "./model/pwrk-cnsltn-doc-crt-rslt-interface";
export {
type PwrkCnsltnDocCrtRsltSaveRequestDto,
@@ -9,10 +9,22 @@ export interface PwrkCnsltnDocCrtRsltResponseDto {
crtCmptnDt?: string;
}
/** 백엔드 PwrkCnsltnDocCrtRsltUpdateRequestDto (모두 optional) */
export interface PwrkCnsltnDocCrtRsltUpdateRequestDto {
rgtrUuid?: string;
orgnlOtptDocCn?: string;
crtCmptnDt?: string;
}
export interface PwrkCnsltnDocCrtRsltService {
findLatestByCrtDmndUuid(crtDmndUuid: string): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
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>;
updateMultipart(
crtDmndUuid: string,
verSn: number,
formData: FormData,
): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
}
@@ -6,10 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib";
import { parseFilenameFromResponse } from "@/entities/attach";
import {
pwrkCnsltnDocCrtRsltSaveRequestSchema,
pwrkCnsltnDocCrtRsltService,
} from "@/entities/pwrk-cnsltn-doc-crt-rslt";
import { pwrkCnsltnDocCrtRsltService } from "@/entities/pwrk-cnsltn-doc-crt-rslt";
import {
findCompletedPwrkDraft,
pwrkCnsltnMngKeys,
@@ -68,20 +65,23 @@ function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDo
}
async function handleSaveDocument(file: Blob) {
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다.");
const crtRslt = completedDraft?.crtRslt;
if (!completedDraft || crtRslt?.verSn == null) {
throw new Error("저장할 실무협의서 초안이 없습니다.");
}
const { crtDmndUuid } = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
crtDmndUuid: completedDraft.crtDmndUuid,
});
const formData = new FormData();
formData.append("crtDmndUuid", crtDmndUuid);
formData.append("file", file, documentFileName);
const savedDocument = await pwrkCnsltnDocCrtRsltService.saveMultipart(formData);
await pwrkCnsltnDocCrtRsltService.updateMultipart(
completedDraft.crtDmndUuid,
crtRslt.verSn,
formData,
);
await queryClient.invalidateQueries({
queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo),
});
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
alert("실무협의서를 저장했습니다.");
}
async function handleDownload() {
@@ -19,6 +19,26 @@ export interface HwpCtrlOpenCallbackResult {
export type HwpCtrlOpenCallback = (result: HwpCtrlOpenCallbackResult) => void;
/** SaveAs 가이드 + 한컴 포럼 실측 콜백 필드 */
export interface HwpCtrlSaveAsCallbackResult {
result: boolean;
path?: string;
fileName?: string;
Size?: number;
size?: number;
CallbackUserData?: unknown;
downloadUrl?: string;
downloadName?: string;
uniqueId?: string;
resultCode?: number;
/** 일부 빌드에만 존재할 수 있는 폴백 필드 */
data?: unknown;
message?: string;
[key: string]: unknown;
}
export type HwpCtrlSaveAsCallback = (result: HwpCtrlSaveAsCallbackResult) => void;
/**
* 실무협의서 관리에서 사용하는 HwpCtrl 메서드만 선언.
* 실제 런타임 객체는 iframe contentWindow에 존재한다.
@@ -35,9 +55,9 @@ export interface HwpCtrl {
fileName: string,
format: string,
option: string,
callback?: HwpCtrlCallback,
extra?: Record<string, unknown>,
) => void;
callback?: HwpCtrlSaveAsCallback,
callbackUserData?: unknown,
) => boolean;
}
export interface HwpCtrlFrameWindow {
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WEBHYP_CTRL_URL } from "@/shared/config";
import { cn, ScrollArea, ScrollBar } from "@common/components";
import { cn } from "@common/components";
import { getHwpCtrl } from "../lib/get-hwp-ctrl";
import {
@@ -11,7 +11,7 @@ import {
HANGULGIANGI_READY_MESSAGE_TYPE,
HWP_CTRL_IFRAME_NAME,
} from "../lib/hangul-giangi-constants";
import type { HwpCtrl } from "../model/hwp-ctrl";
import type { HwpCtrl, HwpCtrlSaveAsCallbackResult } from "../model/hwp-ctrl";
import { HangulGiangiToolbar } from "./hangul-giangi-toolbar";
@@ -30,28 +30,11 @@ function HangulGiangi({
const openedDocumentKeyRef = useRef<string | null>(null);
const openingDocumentKeyRef = useRef<string | null>(null);
const attemptedDocumentKeyRef = useRef<string | null>(null);
const logSequenceRef = useRef(0);
const logEndRef = useRef<HTMLDivElement>(null);
const savingDocumentKeyRef = useRef<string | null>(null);
const [isCtrlReady, setIsCtrlReady] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [logs, setLogs] = useState<HangulGiangiLog[]>([]);
const iframeSrc = useMemo(() => buildBridgeSrc(bridgeUrl, ctrlBaseUrl), [bridgeUrl, ctrlBaseUrl]);
const appendLog = useCallback((message: string) => {
console.log(message);
setLogs((currentLogs) => [
...currentLogs,
{
id: ++logSequenceRef.current,
timestamp: formatLogTimestamp(new Date()),
message,
},
]);
}, []);
useEffect(() => {
logEndRef.current?.scrollIntoView({ block: "nearest" });
}, [logs]);
useEffect(() => {
function handleMessage(event: MessageEvent) {
@@ -60,13 +43,12 @@ function HangulGiangi({
if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return;
setIsCtrlReady(true);
appendLog("기안기 초기화가 완료되었습니다.");
onReady?.();
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [appendLog, onReady]);
}, [onReady]);
useEffect(() => {
if (isCtrlReady) return;
@@ -75,26 +57,23 @@ function HangulGiangi({
const timer = window.setInterval(() => {
if (getHwpCtrl(iframeRef.current)) {
setIsCtrlReady(true);
appendLog("기안기 초기화가 완료되었습니다.");
onReady?.();
window.clearInterval(timer);
return;
}
if (Date.now() - startedAt > HWP_CTRL_READY_TIMEOUT_MS) {
appendLog("웹한글 기안기 초기화 시간이 초과되었습니다.");
window.clearInterval(timer);
}
}, HWP_CTRL_POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [appendLog, isCtrlReady, onReady]);
}, [isCtrlReady, onReady]);
const handleOpen = useCallback(() => {
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
if (!ctrl) return;
setIsBusy(true);
appendLog("실무협의서를 불러오고 있습니다.");
openingDocumentKeyRef.current = documentKey;
attemptedDocumentKeyRef.current = documentKey;
try {
@@ -111,28 +90,22 @@ function HangulGiangi({
openingDocumentKeyRef.current = null;
setIsBusy(false);
if (!result.result) {
appendLog(result.message ?? "문서를 열지 못했습니다.");
return;
}
if (!result.result) return;
openedDocumentKeyRef.current = callbackDocumentKey;
appendLog("실무협의서를 열었습니다.");
},
{ documentKey } satisfies OpenCallbackUserData,
{ documentKey } satisfies DocumentCallbackUserData,
);
if (isAccepted) return;
openingDocumentKeyRef.current = null;
setIsBusy(false);
appendLog("웹한글 기안기가 문서 열기 요청을 접수하지 못했습니다.");
} catch (error) {
} catch {
openingDocumentKeyRef.current = null;
setIsBusy(false);
appendLog(getErrorMessage(error, "문서를 열지 못했습니다."));
}
}, [appendLog, documentBlob, documentFileName, documentKey, isCtrlReady]);
}, [documentBlob, documentFileName, documentKey, isCtrlReady]);
useEffect(() => {
if (
@@ -151,54 +124,55 @@ function HangulGiangi({
if (!ctrl) return;
setIsBusy(true);
appendLog("실무협의서를 저장하고 있습니다.");
savingDocumentKeyRef.current = documentKey;
try {
ctrl.SaveAs(documentFileName, "HWPX", "download:false", async (result) => {
try {
if (result.result === false) {
throw new Error(result.message ?? "기안기에서 문서를 추출하지 못했습니다.");
}
const isAccepted = ctrl.SaveAs(
documentFileName,
"HWPX",
"download:false",
async (result) => {
const callbackDocumentKey = getCallbackDocumentKey(result.CallbackUserData, documentKey);
if (savingDocumentKeyRef.current !== callbackDocumentKey) return;
const documentBlob = decodeDocumentData(result.data);
if (!documentBlob) {
throw new Error("기안기 저장 결과에 문서 데이터가 없습니다.");
}
savingDocumentKeyRef.current = null;
try {
if (!result.result) {
throw new Error(result.message ?? "기안기에서 문서를 추출하지 못했습니다.");
}
await onSaveDocument(documentBlob);
appendLog("실무협의서를 새 버전으로 저장했습니다.");
} catch (error) {
appendLog(getErrorMessage(error, "문서 저장에 실패했습니다."));
} finally {
setIsBusy(false);
}
});
} catch (error) {
const savedDocumentBlob = await resolveSaveAsDocumentBlob(result);
await onSaveDocument(savedDocumentBlob);
} finally {
setIsBusy(false);
}
},
{ documentKey } satisfies DocumentCallbackUserData,
);
if (isAccepted) return;
savingDocumentKeyRef.current = null;
setIsBusy(false);
} catch {
savingDocumentKeyRef.current = null;
setIsBusy(false);
appendLog(getErrorMessage(error, "문서 저장에 실패했습니다."));
}
}, [appendLog, documentFileName, isCtrlReady, onSaveDocument]);
}, [documentFileName, documentKey, isCtrlReady, onSaveDocument]);
const handleDownload = useCallback(async () => {
setIsBusy(true);
appendLog("실무협의서를 다운로드하고 있습니다.");
try {
await onDownload();
appendLog("실무협의서를 다운로드했습니다.");
} catch (error) {
appendLog(getErrorMessage(error, "문서 다운로드에 실패했습니다."));
} finally {
setIsBusy(false);
}
}, [appendLog, onDownload]);
}, [onDownload]);
const handleFullscreen = useCallback(async () => {
if (!iframeRef.current) {
appendLog("전체화면으로 표시할 웹한글 기안기를 찾지 못했습니다.");
return;
}
if (!iframeRef.current) return;
await iframeRef.current.requestFullscreen();
}, [appendLog]);
}, []);
return (
<div
@@ -210,33 +184,10 @@ function HangulGiangi({
<HangulGiangiToolbar
isBusy={isBusy || !isCtrlReady}
onSave={handleSave}
onDownload={() => void handleDownload()}
onFullscreen={() => void handleFullscreen()}
onDownload={handleDownload}
onFullscreen={handleFullscreen}
/>
<ScrollArea
className="h-14 shrink-0 border-b border-border bg-muted"
role="log"
aria-live="polite"
aria-label="웹한글 기안기 작업 로그"
>
<div className="min-w-max px-3 py-2 text-lg text-muted-foreground">
{logs.length > 0 ? (
logs.map((log) => (
<p key={log.id} className="whitespace-nowrap">
<time className="text-foreground/60 mr-2">{log.timestamp}</time>
{log.message}
</p>
))
) : (
<p className="whitespace-nowrap">기안기를 불러오고 있습니다.</p>
)}
<div ref={logEndRef} />
</div>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
<iframe
ref={iframeRef}
id={HWP_CTRL_IFRAME_NAME}
@@ -272,6 +223,35 @@ function buildBridgeSrc(bridgeUrl: string, ctrlBaseUrl: string): string {
return url.toString();
}
async function resolveSaveAsDocumentBlob(result: HwpCtrlSaveAsCallbackResult): Promise<Blob> {
const downloadCandidate = result.downloadUrl ?? result.path;
if (typeof downloadCandidate === "string" && downloadCandidate.length > 0) {
const sameOriginUrl = toSameOriginWebhwpUrl(downloadCandidate);
const response = await fetch(sameOriginUrl);
if (!response.ok) {
throw new Error(`기안기 저장 파일을 가져오지 못했습니다. (${response.status})`);
}
return response.blob();
}
const fromData = decodeDocumentData(result.data);
if (fromData) return fromData;
throw new Error("기안기 저장 결과에 문서 데이터가 없습니다.");
}
function toSameOriginWebhwpUrl(candidate: string): string {
try {
const url = new URL(candidate, window.location.origin);
if (url.pathname.startsWith("/webhwpctrl/")) {
return `${window.location.origin}${url.pathname}${url.search}${url.hash}`;
}
return candidate;
} catch {
return candidate;
}
}
function decodeDocumentData(data: unknown): Blob | null {
if (data instanceof Blob) return data;
if (data instanceof ArrayBuffer) {
@@ -290,19 +270,6 @@ function decodeDocumentData(data: unknown): Blob | null {
return new Blob([bytes], { type: HWPX_CONTENT_TYPE });
}
function getErrorMessage(error: unknown, fallbackMessage: string): string {
return error instanceof Error ? error.message : fallbackMessage;
}
function formatLogTimestamp(date: Date): string {
return date.toLocaleTimeString("ko-KR", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function getCallbackDocumentKey(callbackUserData: unknown, fallbackDocumentKey: string): string {
if (
typeof callbackUserData === "object" &&
@@ -319,16 +286,10 @@ const HWP_CTRL_POLL_INTERVAL_MS = 500;
const HWP_CTRL_READY_TIMEOUT_MS = 60_000;
const HWPX_CONTENT_TYPE = "application/haansofthwpx";
interface OpenCallbackUserData {
interface DocumentCallbackUserData {
documentKey: string;
}
interface HangulGiangiLog {
id: number;
timestamp: string;
message: string;
}
interface HangulGiangiProps {
documentBlob: Blob;
documentKey: string;