diff --git a/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx b/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx new file mode 100644 index 000000000..95c057a58 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/hangulgiangi/page.tsx @@ -0,0 +1,3 @@ +import { HangulGiangiPreviewPage } from "@/pages/hangulgiangi"; + +export default HangulGiangiPreviewPage; diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts index 606194380..e000661fb 100644 --- a/apps/as/next.config.ts +++ b/apps/as/next.config.ts @@ -2,6 +2,12 @@ import type { NextConfig } from "next"; const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; +const webhwpCtrlBase = ( + process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl" +).replace(/\/$/, ""); + +const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const; + const nextConfig: NextConfig = { output: "standalone", reactStrictMode: true, @@ -30,6 +36,10 @@ const nextConfig: NextConfig = { source: "/cms/:path*", destination: `${subDomain}/cms/:path*`, }, + ...webhwpAssetPrefixes.map((prefix) => ({ + source: `/webhwpctrl/${prefix}/:path*`, + destination: `${webhwpCtrlBase}/${prefix}/:path*`, + })), ], fallback: [], }; diff --git a/apps/as/public/hangulgiangi/demo/결재_표.hwpx b/apps/as/public/hangulgiangi/demo/결재_표.hwpx new file mode 100644 index 000000000..3ace9b97c Binary files /dev/null and b/apps/as/public/hangulgiangi/demo/결재_표.hwpx differ diff --git a/apps/as/public/hangulgiangi/demo/결재_표2.hwpx b/apps/as/public/hangulgiangi/demo/결재_표2.hwpx new file mode 100644 index 000000000..d5eaf795b Binary files /dev/null and b/apps/as/public/hangulgiangi/demo/결재_표2.hwpx differ diff --git a/apps/as/public/hangulgiangi/demo/보고서.hwpx b/apps/as/public/hangulgiangi/demo/보고서.hwpx new file mode 100644 index 000000000..d6617699e Binary files /dev/null and b/apps/as/public/hangulgiangi/demo/보고서.hwpx differ diff --git a/apps/as/public/webhwpctrl/hwpsdkmain.html b/apps/as/public/webhwpctrl/hwpsdkmain.html new file mode 100644 index 000000000..d24c7eee7 --- /dev/null +++ b/apps/as/public/webhwpctrl/hwpsdkmain.html @@ -0,0 +1,41 @@ + + + + + + + + Hwp SDK Wasm Sample + + + + + + + + +
+
+
+ 구성 중 ... 0% + +
+
+ +
+
+ + + + + + diff --git a/apps/as/public/webhwpctrl/index.html b/apps/as/public/webhwpctrl/index.html new file mode 100644 index 000000000..8dc0fc313 --- /dev/null +++ b/apps/as/public/webhwpctrl/index.html @@ -0,0 +1,65 @@ + + + + + + + 웹한글 기안기 브리지 + + + + + + +
+ + + diff --git a/apps/as/src/pages/hangulgiangi/index.ts b/apps/as/src/pages/hangulgiangi/index.ts new file mode 100644 index 000000000..f66bdbbb3 --- /dev/null +++ b/apps/as/src/pages/hangulgiangi/index.ts @@ -0,0 +1 @@ +export { HangulGiangiPreviewPage } from "./ui/hangul-giangi-preview-page"; diff --git a/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx b/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx new file mode 100644 index 000000000..0989b99ee --- /dev/null +++ b/apps/as/src/pages/hangulgiangi/ui/hangul-giangi-preview-page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { HangulGiangi } from "@/widgets/hangulgiangi"; + +function HangulGiangiPreviewPage() { + return ( +
+
+

웹한글 기안기 미리보기

+

+ 샘플 툴바·BuildWebHwpCtrl 브리지 연동 검증용 페이지입니다. 메뉴에는 등록하지 않습니다. +

+

+ Demo 파일( + 보고서.hwpx,{" "} + 결재_표.hwpx 등)은{" "} + public/hangulgiangi/demo/에 배치하세요. +

+

+ iframe은 로컬{" "} + /webhwpctrl/index.html 브리지를 로드하고,{" "} + BuildWebHwpCtrl로 같은 origin에서 HwpCtrl을 + 노출합니다. 초기화 스크립트는{" "} + hwp.onasys.co.kr/webhwpctrl에서 가져옵니다. +

+
+ +
+ ); +} + +export { HangulGiangiPreviewPage }; diff --git a/apps/as/src/shared/config/env.ts b/apps/as/src/shared/config/env.ts index f06c95d2b..542d546ad 100644 --- a/apps/as/src/shared/config/env.ts +++ b/apps/as/src/shared/config/env.ts @@ -10,3 +10,7 @@ export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-t export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; export const AS_DASHBOARD_BBS_ID = process.env.NEXT_PUBLIC_AS_DASHBOARD_BBS_ID; + +/** 웹한글 기안기(WebHWP) 컨트롤 URL */ +export const WEBHYP_CTRL_URL = + process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl"; diff --git a/apps/as/src/widgets/hangulgiangi/index.ts b/apps/as/src/widgets/hangulgiangi/index.ts new file mode 100644 index 000000000..e61f86007 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/index.ts @@ -0,0 +1,23 @@ +export { getHwpCtrl } from "./lib/get-hwp-ctrl"; +export { + APPROVAL_FIELD_NAME, + DEFAULT_BRIDGE_URL, + DEFAULT_CTRL_URL, + DEFAULT_DEMO_BASE_URL, + DEFAULT_IFRAME_HEIGHT_CLASS, + DEMO_FILES, + HANGULGIANGI_READY_MESSAGE_TYPE, + HWP_CTRL_IFRAME_NAME, + SIGN_FIELD_NAME, +} from "./lib/hangul-giangi-constants"; +export type { + HwpCtrl, + HwpCtrlCallback, + HwpCtrlCallbackResult, + HwpCtrlFrameWindow, +} from "./model/hwp-ctrl"; +export { HangulGiangi, type HangulGiangiProps } from "./ui/hangul-giangi"; +export { + HangulGiangiToolbar, + type HangulGiangiToolbarProps, +} from "./ui/hangul-giangi-toolbar"; diff --git a/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts b/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts new file mode 100644 index 000000000..3ccbb16b8 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/lib/fetch-demo-blob.ts @@ -0,0 +1,21 @@ +/** + * Demo 정적 파일을 부모 origin에서 fetch해 Blob으로 반환한다. + * iframe origin에서 URL을 직접 Open하면 CORS로 실패할 수 있어 Blob Open을 우선한다. + */ +async function fetchDemoBlob(demoBaseUrl: string, fileName: string): Promise { + const base = demoBaseUrl.endsWith("/") ? demoBaseUrl : `${demoBaseUrl}/`; + const url = `${base}${encodeURIComponent(fileName)}`; + const response = await fetch(url); + + if (!response.ok) + throw new Error(`Demo 파일을 불러오지 못했습니다: ${fileName} (${response.status})`); + + return response.blob(); +} + +function getFormatFromFileName(fileName: string): string { + const ext = fileName.split(".").pop()?.toUpperCase(); + return ext ?? ""; +} + +export { fetchDemoBlob, getFormatFromFileName }; diff --git a/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts b/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts new file mode 100644 index 000000000..58757ee70 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts @@ -0,0 +1,29 @@ +import type { HwpCtrl, HwpCtrlFrameWindow } from "../model/hwp-ctrl"; + +import { HWP_CTRL_IFRAME_NAME } from "./hangul-giangi-constants"; + +interface WindowWithNamedHwpFrame extends Window { + Document_HwpCtrl?: Window & HwpCtrlFrameWindow; +} + +/** + * iframe ref 우선, 없으면 named frame 폴백으로 HwpCtrl을 반환한다. + * 로컬 `/webhwpctrl` 브리지는 same-origin이라 contentWindow 접근이 가능하다. + */ +function getHwpCtrl(iframe: HTMLIFrameElement | null): HwpCtrl | null { + if (typeof window === "undefined") return null; + + try { + const fromRef = iframe?.contentWindow as HwpCtrlFrameWindow | null | undefined; + if (fromRef?.HwpCtrl) return fromRef.HwpCtrl; + + const namedFrame = (window as WindowWithNamedHwpFrame)[HWP_CTRL_IFRAME_NAME]; + if (namedFrame?.HwpCtrl) return namedFrame.HwpCtrl; + + return null; + } catch { + return null; + } +} + +export { getHwpCtrl }; diff --git a/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts b/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts new file mode 100644 index 000000000..15a330f81 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/lib/hangul-giangi-constants.ts @@ -0,0 +1,25 @@ +import { WEBHYP_CTRL_URL } from "@/shared/config"; + +/** iframe name — 한컴 named-iframe / 브리지 contentWindow 접근용 */ +export const HWP_CTRL_IFRAME_NAME = "Document_HwpCtrl"; + +/** 로컬 BuildWebHwpCtrl 브리지 (same-origin) */ +export const DEFAULT_BRIDGE_URL = "/webhwpctrl/index.html"; + +/** 기안기 서버 base URL (브리지 BuildWebHwpCtrl baseurl / rewrite destination) */ +export const DEFAULT_CTRL_URL = WEBHYP_CTRL_URL; + +/** Demo 정적 파일 base path (`apps/as/public/hangulgiangi/demo/`) */ +export const DEFAULT_DEMO_BASE_URL = "/hangulgiangi/demo/"; + +export const DEMO_FILES = { + report: "보고서.hwpx", + approvalTable1: "결재_표.hwpx", + approvalTable2: "결재_표2.hwpx", + signImage: "sign.png", +} as const; + +export const APPROVAL_FIELD_NAME = "결재영역"; +export const SIGN_FIELD_NAME = "결재1"; + +export const HANGULGIANGI_READY_MESSAGE_TYPE = "hangulgiangi-ready"; diff --git a/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts new file mode 100644 index 000000000..435e4fa0d --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts @@ -0,0 +1,58 @@ +/** 한컴 웹한글 기안기 HwpCtrl 콜백 결과 (문서화되지 않은 필드 허용) */ +export interface HwpCtrlCallbackResult { + result?: boolean | string | number; + message?: string; + [key: string]: unknown; +} + +export type HwpCtrlCallback = (res: HwpCtrlCallbackResult) => void; + +/** + * 샘플(v3_sample)에서 사용하는 HwpCtrl 메서드만 선언. + * 실제 런타임 객체는 iframe contentWindow에 존재한다. + */ +export interface HwpCtrl { + Open: ( + source: string | Blob | File, + format: string, + option: string, + callback?: HwpCtrlCallback, + extra?: Record, + ) => void; + Insert: ( + source: string | Blob | File, + format: string, + option: string, + callback?: HwpCtrlCallback, + ) => void; + InsertPicture: ( + source: string | Blob | File, + embedded: boolean, + sizeOption: number, + reverse: boolean, + watermark: boolean, + effect: number, + width: number, + height: number, + callback?: HwpCtrlCallback, + ) => void; + MoveToField: ( + fieldName: string, + text: boolean, + start: boolean, + select: boolean, + ) => void; + Run: (action: string) => void; + SaveAs: ( + fileName: string, + format: string, + option: string, + callback?: HwpCtrlCallback, + extra?: Record, + ) => void; + Clear: () => void; +} + +export interface HwpCtrlFrameWindow { + HwpCtrl?: HwpCtrl; +} diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx new file mode 100644 index 000000000..a28836a02 --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { Button, cn } from "@common/components"; + +interface HangulGiangiToolbarProps { + isApprovalMenuOpen: boolean; + isBusy?: boolean; + onOpen: () => void; + onToggleApprovalMenu: () => void; + onCloseApprovalMenu: () => void; + onApproval1: () => void; + onApproval2: () => void; + onSign: () => void; + onSave: () => void; + onClear: () => void; + onPickLocalFile: () => void; +} + +function HangulGiangiToolbar({ + isApprovalMenuOpen, + isBusy = false, + onOpen, + onToggleApprovalMenu, + onCloseApprovalMenu, + onApproval1, + onApproval2, + onSign, + onSave, + onClear, + onPickLocalFile, +}: HangulGiangiToolbarProps) { + const approvalMenuRef = useRef(null); + + useEffect(() => { + if (!isApprovalMenuOpen) return; + + function handlePointerDown(event: MouseEvent) { + const target = event.target as Node; + if (approvalMenuRef.current?.contains(target)) return; + onCloseApprovalMenu(); + } + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [isApprovalMenuOpen, onCloseApprovalMenu]); + + return ( +
+ + +
+ +
+ + +
+
+ + + + + +
+ ); +} + +export { HangulGiangiToolbar }; +export type { HangulGiangiToolbarProps }; diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx new file mode 100644 index 000000000..7f2a7516e --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { WEBHYP_CTRL_URL } from "@/shared/config"; +import { cn } from "@common/components"; + +import { fetchDemoBlob } from "../lib/fetch-demo-blob"; +import { getHwpCtrl } from "../lib/get-hwp-ctrl"; +import { + APPROVAL_FIELD_NAME, + DEFAULT_BRIDGE_URL, + DEFAULT_DEMO_BASE_URL, + DEMO_FILES, + HANGULGIANGI_READY_MESSAGE_TYPE, + HWP_CTRL_IFRAME_NAME, + SIGN_FIELD_NAME, +} from "../lib/hangul-giangi-constants"; +import type { HwpCtrl } from "../model/hwp-ctrl"; + +import { HangulGiangiToolbar } from "./hangul-giangi-toolbar"; + +interface HangulGiangiProps { + /** 로컬 BuildWebHwpCtrl 브리지 URL (기본 `/webhwpctrl/index.html`) */ + bridgeUrl?: string; + /** @deprecated bridgeUrl 사용. 하위 호환용 alias */ + ctrlUrl?: string; + /** 기안기 서버 base URL (브리지 `?baseurl=` 로 전달) */ + ctrlBaseUrl?: string; + demoBaseUrl?: string; + className?: string; + onReady?: () => void; +} + +function resolveHwpCtrl(iframe: HTMLIFrameElement | null, isCtrlReady: boolean): HwpCtrl | null { + const ctrl = getHwpCtrl(iframe); + if (!ctrl) { + alert( + isCtrlReady + ? "웹한글 기안기 컨트롤을 찾지 못했습니다. 브리지 페이지를 확인하세요." + : "웹한글 기안기가 아직 초기화되지 않았습니다. 잠시 후 다시 시도하세요.", + ); + return null; + } + return ctrl; +} + +function buildBridgeSrc(bridgeUrl: string, ctrlBaseUrl: string): string { + const base = ctrlBaseUrl.replace(/\/$/, ""); + const url = new URL( + bridgeUrl, + typeof window !== "undefined" ? window.location.origin : "http://localhost", + ); + url.searchParams.set("baseurl", base); + // URL()은 absolute path면 pathname만 쓰므로, 상대 경로 브리지는 pathname+search만 반환 + if (bridgeUrl.startsWith("/")) return `${url.pathname}${url.search}`; + return url.toString(); +} + +function HangulGiangi({ + bridgeUrl, + ctrlUrl, + ctrlBaseUrl = WEBHYP_CTRL_URL, + demoBaseUrl = DEFAULT_DEMO_BASE_URL, + className, + onReady, +}: HangulGiangiProps) { + const resolvedBridgeUrl = bridgeUrl ?? ctrlUrl ?? DEFAULT_BRIDGE_URL; + const iframeRef = useRef(null); + const fileInputRef = useRef(null); + + const [isCtrlReady, setIsCtrlReady] = useState(false); + const [isOpened, setIsOpened] = useState(false); + const [isApprovalAdded, setIsApprovalAdded] = useState(false); + const [isApprovalMenuOpen, setIsApprovalMenuOpen] = useState(false); + const [isBusy, setIsBusy] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + + const iframeSrc = useMemo( + () => buildBridgeSrc(resolvedBridgeUrl, ctrlBaseUrl), + [resolvedBridgeUrl, ctrlBaseUrl], + ); + + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== HANGULGIANGI_READY_MESSAGE_TYPE) return; + if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return; + + setIsCtrlReady(true); + setStatusMessage("기안기 초기화가 완료되었습니다."); + onReady?.(); + } + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [onReady]); + + // postMessage 누락 대비: HwpCtrl 존재 poll + useEffect(() => { + if (isCtrlReady) return; + + const startedAt = Date.now(); + const timer = window.setInterval(() => { + if (getHwpCtrl(iframeRef.current)) { + setIsCtrlReady(true); + setStatusMessage("기안기 초기화가 완료되었습니다."); + onReady?.(); + window.clearInterval(timer); + return; + } + if (Date.now() - startedAt > 60_000) window.clearInterval(timer); + }, 500); + + return () => window.clearInterval(timer); + }, [isCtrlReady, onReady]); + + const handleOpen = useCallback(async () => { + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + setIsBusy(true); + setStatusMessage(null); + try { + const blob = await fetchDemoBlob(demoBaseUrl, DEMO_FILES.report); + ctrl.Open(blob, "HWPX", "lock:false", (res) => { + console.log("Open 보고서.hwpx:", res); + }); + setIsOpened(true); + setIsApprovalAdded(false); + setStatusMessage("문서를 열었습니다."); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "문서 열기에 실패했습니다. Demo 파일 경로를 확인하세요.", + ); + } finally { + setIsBusy(false); + } + }, [demoBaseUrl, isCtrlReady]); + + const insertApprovalTable = useCallback( + async (fileName: string) => { + if (!isOpened) { + alert("먼저 '열기' 버튼을 눌러주세요."); + return; + } + + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + setIsBusy(true); + setStatusMessage(null); + try { + ctrl.MoveToField(APPROVAL_FIELD_NAME, true, true, false); + ctrl.Run("SelectAll"); + ctrl.Run("Delete"); + ctrl.MoveToField(APPROVAL_FIELD_NAME, true, true, false); + + const blob = await fetchDemoBlob(demoBaseUrl, fileName); + ctrl.Insert(blob, "HWPX", "", (res) => { + console.log(`Insert ${fileName}:`, res); + }); + + setIsApprovalAdded(true); + setIsApprovalMenuOpen(false); + setStatusMessage(`${fileName} 결재 표를 삽입했습니다.`); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "결재 표 삽입에 실패했습니다. Demo 파일 경로를 확인하세요.", + ); + } finally { + setIsBusy(false); + } + }, + [demoBaseUrl, isCtrlReady, isOpened], + ); + + const handleSign = useCallback(async () => { + if (!isApprovalAdded) { + alert("먼저 '결재 추가' 버튼을 눌러주세요."); + return; + } + + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + setIsBusy(true); + setStatusMessage(null); + try { + ctrl.MoveToField(SIGN_FIELD_NAME, true, true, false); + const blob = await fetchDemoBlob(demoBaseUrl, DEMO_FILES.signImage); + ctrl.InsertPicture(blob, true, 1, false, false, 0, 90, 30, (res) => { + console.log("Insert sign.png:", res); + }); + setStatusMessage("결재 이미지를 삽입했습니다."); + } catch (error) { + console.error(error); + alert( + error instanceof Error + ? error.message + : "결재 이미지 삽입에 실패했습니다. Demo 파일 경로를 확인하세요.", + ); + } finally { + setIsBusy(false); + } + }, [demoBaseUrl, isApprovalAdded, isCtrlReady]); + + const handleSave = useCallback(() => { + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + ctrl.SaveAs( + "sample.hwp", + "HWP", + "download:true;", + (res) => { + console.log(res); + setStatusMessage("HWP 저장을 요청했습니다."); + }, + { tempUrl: typeof window !== "undefined" ? window.location.href : "" }, + ); + }, [isCtrlReady]); + + const handleClear = useCallback(() => { + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) return; + + ctrl.Clear(); + setIsOpened(false); + setIsApprovalAdded(false); + setIsApprovalMenuOpen(false); + setStatusMessage("문서를 클리어했습니다."); + }, [isCtrlReady]); + + const handleLocalFileChange = useCallback( + (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady); + if (!ctrl) { + event.target.value = ""; + return; + } + + const blobData = new Blob([file], { type: file.type }); + ctrl.Open(blobData, "", "lock:false", (res) => { + ctrl.MoveToField(file.name, true, true, true); + console.log(res); + }); + + setIsOpened(true); + setIsApprovalAdded(false); + setStatusMessage(`${file.name} 파일을 열었습니다.`); + event.target.value = ""; + }, + [isCtrlReady], + ); + + const isToolbarBusy = isBusy || !isCtrlReady; + + return ( +
+ setIsApprovalMenuOpen((prev) => !prev)} + onCloseApprovalMenu={() => setIsApprovalMenuOpen(false)} + onApproval1={() => void insertApprovalTable(DEMO_FILES.approvalTable1)} + onApproval2={() => void insertApprovalTable(DEMO_FILES.approvalTable2)} + onSign={() => void handleSign()} + onSave={handleSave} + onClear={handleClear} + onPickLocalFile={() => fileInputRef.current?.click()} + /> + + {statusMessage ? ( +

+ {statusMessage} +

+ ) : !isCtrlReady ? ( +

+ 기안기 초기화 중… +

+ ) : null} + +