This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user