[as] feat: 실무협의서 웹기안기에 렌더링

This commit is contained in:
2026-09-09 17:38:40 +09:00
parent 768ad001d2
commit e944ad4369
29 changed files with 690 additions and 471 deletions
@@ -1,3 +0,0 @@
import { HangulGiangiPreviewPage } from "@/pages/hangulgiangi";
export default HangulGiangiPreviewPage;
@@ -144,7 +144,7 @@ SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA
7. **보완요청/보완완료는 공통 API** `reqsupplement` / `submitsupplement`. 복귀 상태(`ER`/`TE`/`RK`)는 서버가 이력에서 결정한다. 프론트는 직전 상태를 추측해 보내지 않는다.
8. **결재 승인/회신은** `PUT /elapr-prgrs-stts/{id}/process` **하나**. 1차 최종승인자 → `TP`, 2차 → `RR`은 서버가 Feign으로 처리한다.
9. **2차 결재선 복사는 백엔드**. `POST /elapr-apln/copy-line`은 `AV→RK` 콜백에서 호출한다. 프론트는 `RK`에서 2차 결재선을 **조회·표시**만 한다.
10. **웹기안기는 URL을 직접 Open**. blob Object URL을 `HwpCtrl.Open`에 넘기지 않는다. `orgnlOtptDocCn`은 쓰지 않는다.
10. **웹기안기는 `webhwp/open` 응답을 axios `blob`으로 조회한 뒤 `File`로 감싸 `HwpCtrl.Open`에 전달한다.** `blob:` Object URL과 `orgnlOtptDocCn`은 쓰지 않는다.
---
@@ -187,7 +187,7 @@ BEGIN
ON [파일보완] (ER, ATRZ01) → GET file-vrfc-results → 팝업 → reqsupplement
ON [파일검증] (ER, ATRZ01) → dcrag-to transfer (상태 유지)
ON [검토자지정] (ER, ATRZ01) → POST elapr-dmnd (docPrgrsSttsComCd: AC) → START POLL AC
ON [실무협의서관리] (TE|RK) → HwpCtrl.Open(webhwp/open)
ON [실무협의서관리] (TE|RK) → GET webhwp/open (blob) → HwpCtrl.Open(File)
ON [실무협의서생성] (TE, 실패/타임아웃) → dcrag-to draft/generate
ON [검토결과조회] (TE) → pwrk-cnsltn-mng findById | findByUpDocNo
ON [저장] (TE|RK) → pwrk-cnsltn-mng save-apply
+2 -2
View File
@@ -257,7 +257,7 @@ v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**.
| ID | 용도 | URI | 응답 |
| --- | --- | --- | --- |
| W1 | `HwpCtrl.Open` | `GET .../pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open` | `inline` HWPX |
| W1 | 웹기안기 Open | `GET .../pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open` | `inline` HWPX blob → `HwpCtrl.Open(File)` |
| W2 | 편집 저장 | `POST .../pwrk-cnsltn-doc-crt-rslt` multipart `file` + `crtDmndUuid` | 201, `verSn+1` |
| W3 | PC 저장 | `GET .../{crtDmndUuid}/download` | `attachment` blob |
@@ -271,7 +271,7 @@ v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**.
- 서버 파일명: `재원협의 실무협의서({bizNm})_v{verSn}.hwp`
- Content-Type은 `application/x-hwp` 또는 `application/haansofthwpx`일 수 있음. 타입에 의존하지 말 것
Open은 **절대 URL**을 기안기에 전달. blob Object URL 금지. 쿠키/게이트웨이 인증 가능 여부는 백엔드 확인.
Open은 axios `responseType: "blob"`으로 인증된 API를 호출하고, 응답 Blob을 HWPX `File`로 감싸 `HwpCtrl.Open`에 전달한다. `blob:` Object URL은 사용하지 않는다.
multipart `Content-Type` 헤더를 수동 지정하지 않는다 (boundary). JSON `saveJson`은 UI에서 쓰지 않는다.
@@ -105,6 +105,7 @@ export interface PwrkCnsltnDocCrtRsltService {
): Promise<PageResponse<PwrkCnsltnDocCrtRsltResponseDto>>;
findById(crtDmndUuid: string, verSn: number): Promise<PwrkCnsltnDocCrtRsltResponseDto>;
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>;
@@ -117,15 +118,18 @@ export interface PwrkCnsltnDocCrtRsltService {
}
```
`webhwp/open`은 fetch하지 않고 URL 빌더만:
`webhwp/open`은 인증된 axios 요청으로 Blob을 조회한다:
```ts
function webhwpOpenUrl(crtDmndUuid: string, verSn?: number): string {
const base = `${ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT}/${crtDmndUuid}`;
return verSn == null ? `${base}/webhwp/open` : `${base}/${verSn}/webhwp/open`;
async function openWebHwpLatest(crtDmndUuid: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
responseType: "blob",
});
}
```
응답 Blob은 HWPX `File`로 감싸 `HwpCtrl.Open(file, "HWPX", ...)`에 전달한다. `blob:` Object URL은 사용하지 않는다.
DC RAG TO (구현됨, 시그니처 유지):
```ts
@@ -241,6 +241,6 @@ stateDiagram-v2
## 8. 웹기안기
- `TE`/`RK` [실무협의서관리] → 팝업에서 `HwpCtrl.Open(webhwp/open URL)`.
- `TE`/`RK` [실무협의서관리] → `webhwp/open` Blob 조회 후 팝업에서 `HwpCtrl.Open(File)`.
- [검토완료] 시 HWP 파싱·DB 등재는 **서버**. FE는 결재 `process`만.
- 편집본 저장: `POST /pwrk-cnsltn-doc-crt-rslt` multipart. `verSn`은 클라이언트가 보내지 않음.
+8 -7
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="ko" style="height: 100%">
<head>
<meta charset="utf-8" />
@@ -28,6 +28,11 @@
overflow: hidden;
border: 0;
}
#hwpctrl_frame {
width: 100% !important;
height: 100% !important;
}
</style>
</head>
<body>
@@ -35,8 +40,7 @@
<script type="text/javascript">
(function () {
var params = new URLSearchParams(window.location.search);
var baseUrl =
params.get("baseurl") || "http://hwp.onasys.co.kr/webhwpctrl";
var baseUrl = params.get("baseurl") || "http://hwp.onasys.co.kr/webhwpctrl";
if (baseUrl.charAt(baseUrl.length - 1) !== "/") {
baseUrl += "/";
}
@@ -46,10 +50,7 @@
baseUrl,
function () {
try {
window.parent.postMessage(
{ type: "hangulgiangi-ready" },
"*",
);
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
} catch (e) {
console.warn(e);
}
+1
View File
@@ -4,4 +4,5 @@ export {
useDownloadAttachmentByAtflCd,
useDownloadAttachmentByAtflId,
} from "./api/queries";
export { parseFilenameFromResponse } from "./lib/parse-filename-from-response";
export type { Attach, AttachService } from "./model/attach-interface";
@@ -0,0 +1,51 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PwrkCnsltnDocCrtRsltResponseDto,
PwrkCnsltnDocCrtRsltService,
} from "../model/pwrk-cnsltn-doc-crt-rslt-interface";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_DOC_CRT_RSLT;
async function findLatestByCrtDmndUuid(
crtDmndUuid: string,
): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const response = await client.get<PwrkCnsltnDocCrtRsltResponseDto>(
`${BASE}/${crtDmndUuid}/latest`,
);
return response.data;
}
async function openWebHwpLatest(crtDmndUuid: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/webhwp/open`, {
responseType: "blob",
});
}
async function download(crtDmndUuid: string, verSn: number) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/${verSn}/download`, {
responseType: "blob",
});
}
async function downloadLatest(crtDmndUuid: string) {
return client.get<Blob>(`${BASE}/${crtDmndUuid}/download`, {
responseType: "blob",
});
}
async function saveMultipart(formData: FormData): Promise<PwrkCnsltnDocCrtRsltResponseDto> {
const response = await client.post<PwrkCnsltnDocCrtRsltResponseDto>(BASE, formData, {
headers: { "Content-Type": undefined },
});
return response.data;
}
export const pwrkCnsltnDocCrtRsltService = {
findLatestByCrtDmndUuid,
openWebHwpLatest,
download,
downloadLatest,
saveMultipart,
} satisfies PwrkCnsltnDocCrtRsltService;
@@ -0,0 +1,9 @@
export { pwrkCnsltnDocCrtRsltService } from "./api/pwrk-cnsltn-doc-crt-rslt-service";
export type {
PwrkCnsltnDocCrtRsltResponseDto,
PwrkCnsltnDocCrtRsltService,
} from "./model/pwrk-cnsltn-doc-crt-rslt-interface";
export {
type PwrkCnsltnDocCrtRsltSaveRequestDto,
pwrkCnsltnDocCrtRsltSaveRequestSchema,
} from "./model/pwrk-cnsltn-doc-crt-rslt-payload-schema";
@@ -0,0 +1,18 @@
import type { AxiosResponse } from "axios";
export interface PwrkCnsltnDocCrtRsltResponseDto {
crtDmndUuid: string;
verSn: number;
rgtrUuid?: string;
rgtrNm?: string;
orgnlOtptDocCn: null;
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>;
}
@@ -0,0 +1,9 @@
import { z } from "zod";
export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
crtDmndUuid: z.string().uuid(),
});
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
typeof pwrkCnsltnDocCrtRsltSaveRequestSchema
>;
@@ -0,0 +1,18 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
PwrkCnsltnMngDetResponseDto,
PwrkCnsltnMngService,
} from "../model/pwrk-cnsltn-mng-interface";
const BASE = ENDPOINT.AS_SERVICE.PWRK_CNSLTN_MNG;
async function findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto> {
const response = await client.get<PwrkCnsltnMngDetResponseDto>(`${BASE}/updocno/${upDocNo}`);
return response.data;
}
export const pwrkCnsltnMngService = {
findByUpDocNo,
} satisfies PwrkCnsltnMngService;
@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { pwrkCnsltnMngService } from "./pwrk-cnsltn-mng-service";
export const pwrkCnsltnMngKeys = {
all: ["pwrk-cnsltn-mng"] as const,
byUpDocNo: (upDocNo: string) => [...pwrkCnsltnMngKeys.all, "by-up-doc-no", upDocNo] as const,
};
export function usePwrkCnsltnMngByUpDocNo(upDocNo: string | null | undefined, isEnabled = true) {
return useQuery({
queryKey: pwrkCnsltnMngKeys.byUpDocNo(upDocNo!),
queryFn: () => pwrkCnsltnMngService.findByUpDocNo(upDocNo!),
enabled: Boolean(upDocNo) && isEnabled,
});
}
@@ -0,0 +1,9 @@
export { pwrkCnsltnMngService } from "./api/pwrk-cnsltn-mng-service";
export { pwrkCnsltnMngKeys, usePwrkCnsltnMngByUpDocNo } from "./api/queries";
export { findCompletedPwrkDraft } from "./lib/find-completed-draft";
export type {
PwrkCnsltnDocCrtDmndResponseDto,
PwrkCnsltnDocCrtRsltResponseDto,
PwrkCnsltnMngDetResponseDto,
PwrkCnsltnMngService,
} from "./model/pwrk-cnsltn-mng-interface";
@@ -0,0 +1,12 @@
import type {
PwrkCnsltnDocCrtDmndResponseDto,
PwrkCnsltnMngDetResponseDto,
} from "../model/pwrk-cnsltn-mng-interface";
export function findCompletedPwrkDraft(
data: PwrkCnsltnMngDetResponseDto | null | undefined,
): PwrkCnsltnDocCrtDmndResponseDto | undefined {
return data?.crtDmndList?.find(
(item) => item.crtSttsComCd === "C" && Boolean(item.crtDmndUuid) && Boolean(item.crtRslt),
);
}
@@ -0,0 +1,33 @@
export interface PwrkCnsltnDocCrtRsltResponseDto {
crtDmndUuid: string;
verSn: number;
rgtrUuid?: string;
rgtrNm?: string;
orgnlOtptDocCn: null;
crtCmptnDt?: string;
}
export interface PwrkCnsltnDocCrtDmndResponseDto {
crtDmndUuid: string;
pwrkCnsltnDocNo: string;
crtSttsComCd?: string;
crtSttsComCdNm?: string;
failRsn?: string;
dmndDt?: string;
cmptnDt?: string;
crtRslt?: PwrkCnsltnDocCrtRsltResponseDto | null;
}
export interface PwrkCnsltnMngDetResponseDto {
pwrkCnsltnDocNo: string;
upDocNo: string;
upDocSeComCd?: string;
prgrsSttsComCd?: string;
bizNm?: string;
crtDmndList?: PwrkCnsltnDocCrtDmndResponseDto[];
crtRsltList?: PwrkCnsltnDocCrtRsltResponseDto[];
}
export interface PwrkCnsltnMngService {
findByUpDocNo(upDocNo: string): Promise<PwrkCnsltnMngDetResponseDto>;
}
@@ -46,17 +46,14 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE", "RK"] as const;
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
string,
Record<string, FnrsCnsltnViewButtonAction[]>
> = {
ER: {
ATRZ01: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
],
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt, FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
},
TE: {
ATRZ02: [
@@ -69,6 +66,9 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv],
ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply],
},
RK: {
ATRZ02: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
};
function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltnViewButtonAction[] {
@@ -77,17 +77,20 @@ function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltn
interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
hasCompletedDraft?: boolean;
}
const PENDING_APPROVAL_ACTIONS = new Set<FnrsCnsltnViewButtonAction>([
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply,
]);
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): FnrsCnsltnViewButtonAction[] {
if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) {
return [];
}
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
@@ -105,7 +108,20 @@ function resolveRoleUser01AtrzButtonActions(
}
}
return actions;
return actions.filter((action) => {
if (prgrsSttsComCd === "TE" && options?.hasCompletedDraft != null) {
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) {
return options.hasCompletedDraft;
}
if (action === FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocCrt) {
return !options.hasCompletedDraft;
}
}
const needsPendingApproval = PENDING_APPROVAL_ACTIONS.has(action);
return !needsPendingApproval || options?.hasPendingApprovalRequest !== false;
});
}
export function needsRoleUser01AtrzResolve(
@@ -28,6 +28,7 @@ import {
fnrsCnsltnMngService,
type FnrsExamineRequestDto,
} from "@/entities/fnrs-cnsltn";
import { findCompletedPwrkDraft, usePwrkCnsltnMngByUpDocNo } from "@/entities/pwrk-cnsltn-mng";
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
@@ -49,6 +50,7 @@ import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
import { FileVrfcSplmntDialog } from "./file-vrfc-splmnt-dialog";
import { PwrkCnsltnDocMngDialog } from "./pwrk-cnsltn-doc-mng-dialog";
interface FloatingActionButtonsProps {
data: FnrsCnsltnMngDetResponseDto;
@@ -64,7 +66,14 @@ interface FloatingActionButtonsProps {
isAiPending?: boolean;
}
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "fileSplmnt" | "elaprApproval" | null;
type ActiveDialog =
| "elaprTmplt"
| "rjct"
| "dmndSplmnt"
| "fileSplmnt"
| "elaprApproval"
| "pwrkCnsltnDocMng"
| null;
function FloatingActionButtons({
data,
@@ -92,8 +101,14 @@ function FloatingActionButtons({
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
const needsPwrkDraftCheck = dtlInstCd === "ROLE_USER01" && ["TE", "RK"].includes(prgrsSttsComCd);
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
const { data: pwrkCnsltnData, isLoading: isPwrkCnsltnLoading } = usePwrkCnsltnMngByUpDocNo(
docNo,
needsPwrkDraftCheck,
);
const hasCompletedDraft = Boolean(findCompletedPwrkDraft(pwrkCnsltnData));
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
@@ -103,17 +118,22 @@ function FloatingActionButtons({
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
if (needsPwrkDraftCheck && isPwrkCnsltnLoading) return [];
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
hasCompletedDraft: needsPwrkDraftCheck ? hasCompletedDraft : undefined,
});
}, [
atrzSeComCdList,
dtlInstCd,
hasCompletedDraft,
hasPendingApprovalRequest,
isElaprDocPrgrsLoading,
isPwrkCnsltnLoading,
isRvwLoginUserLoading,
needsAtrzResolve,
needsTeApprovalCheck,
needsPwrkDraftCheck,
prgrsSttsComCd,
]);
@@ -490,7 +510,11 @@ function FloatingActionButtons({
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng) && (
<Button color="orange" onClick={() => {}} disabled={isProcessing}>
<Button
color="orange"
onClick={() => setActiveDialog("pwrkCnsltnDocMng")}
disabled={isProcessing}
>
실무협의서 관리
</Button>
)}
@@ -560,6 +584,12 @@ function FloatingActionButtons({
});
}}
/>
<PwrkCnsltnDocMngDialog
open={activeDialog === "pwrkCnsltnDocMng"}
fnrsCnsltnDocNo={docNo}
onClose={() => setActiveDialog(null)}
/>
</>
)}
</>
@@ -0,0 +1,145 @@
"use client";
import dynamic from "next/dynamic";
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 {
findCompletedPwrkDraft,
pwrkCnsltnMngKeys,
usePwrkCnsltnMngByUpDocNo,
} from "@/entities/pwrk-cnsltn-mng";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Skeleton,
} from "@common/components";
const HangulGiangi = dynamic(
() => import("@/widgets/hangulgiangi").then((module) => module.HangulGiangi),
{
ssr: false,
loading: () => <Skeleton className="h-[70vh] min-h-[40rem] w-full" />,
},
);
interface PwrkCnsltnDocMngDialogProps {
open: boolean;
fnrsCnsltnDocNo: string;
onClose: () => void;
}
function PwrkCnsltnDocMngDialog({ open, fnrsCnsltnDocNo, onClose }: PwrkCnsltnDocMngDialogProps) {
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const {
data: pwrkCnsltnData,
isLoading: isPwrkCnsltnLoading,
isError: isPwrkCnsltnError,
} = usePwrkCnsltnMngByUpDocNo(fnrsCnsltnDocNo, open);
const completedDraft = findCompletedPwrkDraft(pwrkCnsltnData);
const crtDmndUuid = completedDraft?.crtDmndUuid;
const {
data: documentBlob,
isLoading: isDocumentLoading,
isError: isDocumentError,
} = useQuery({
queryKey: ["pwrk-cnsltn-doc-crt-rslt", "open-latest", crtDmndUuid],
queryFn: () => pwrkCnsltnDocCrtRsltService.openWebHwpLatest(crtDmndUuid!),
select: (response) => response.data,
enabled: open && Boolean(crtDmndUuid),
});
const documentFileName = `실무협의서_${pwrkCnsltnData?.pwrkCnsltnDocNo ?? crtDmndUuid}.hwpx`;
const isLoading = isPwrkCnsltnLoading || isDocumentLoading;
const isError = isPwrkCnsltnError || isDocumentError;
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
}
async function handleSaveDocument(file: Blob) {
if (!completedDraft) 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 queryClient.invalidateQueries({
queryKey: pwrkCnsltnMngKeys.byUpDocNo(fnrsCnsltnDocNo),
});
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
}
async function handleDownload() {
if (!completedDraft) throw new Error("다운로드할 실무협의서 초안이 없습니다.");
const response = await pwrkCnsltnDocCrtRsltService.downloadLatest(completedDraft.crtDmndUuid);
const objectUrl = window.URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = parseFilenameFromResponse(response);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(objectUrl);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[96vh] max-w-[calc(100vw-3rem)] overflow-y-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>실무협의서 관리</DialogTitle>
</DialogHeader>
{isLoading ? <Skeleton className="h-[70vh] min-h-[40rem] w-full" /> : null}
{isError ? (
<p className="py-16 text-center text-sm text-destructive">
실무협의서 정보를 불러오지 못했습니다.
</p>
) : null}
{!isLoading && !isError && !completedDraft ? (
<p className="py-16 text-center text-sm text-muted-foreground">
생성 완료된 실무협의서 초안이 없습니다.
</p>
) : null}
{completedDraft ? (
documentBlob ? (
<HangulGiangi
documentBlob={documentBlob}
documentKey={completedDraft.crtDmndUuid}
documentFileName={documentFileName}
onSaveDocument={handleSaveDocument}
onDownload={handleDownload}
/>
) : null
) : null}
<DialogFooter>
<Button type="button" color="white" onClick={onClose}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { PwrkCnsltnDocMngDialog };
export type { PwrkCnsltnDocMngDialogProps };
-1
View File
@@ -1 +0,0 @@
export { HangulGiangiPreviewPage } from "./ui/hangul-giangi-preview-page";
@@ -1,32 +0,0 @@
"use client";
import { HangulGiangi } from "@/widgets/hangulgiangi";
function HangulGiangiPreviewPage() {
return (
<div className="flex flex-col gap-4 p-6">
<div>
<h1 className="text-xl font-semibold">웹한글 기안기 미리보기</h1>
<p className="mt-1 text-sm text-muted-foreground">
샘플 툴바·BuildWebHwpCtrl 브리지 연동 검증용 페이지입니다. 메뉴에는 등록하지 않습니다.
</p>
<p className="mt-1 text-sm text-muted-foreground">
Demo 파일(
<code className="rounded bg-muted px-1">보고서.hwpx</code>,{" "}
<code className="rounded bg-muted px-1">결재_표.hwpx</code> 등)은{" "}
<code className="rounded bg-muted px-1">public/hangulgiangi/demo/</code>에 배치하세요.
</p>
<p className="mt-1 text-sm text-muted-foreground">
iframe은 로컬{" "}
<code className="rounded bg-muted px-1">/webhwpctrl/index.html</code> 브리지를 로드하고,{" "}
<code className="rounded bg-muted px-1">BuildWebHwpCtrl</code>로 같은 origin에서 HwpCtrl을
노출합니다. 초기화 스크립트는{" "}
<code className="rounded bg-muted px-1">hwp.onasys.co.kr/webhwpctrl</code>에서 가져옵니다.
</p>
</div>
<HangulGiangi />
</div>
);
}
export { HangulGiangiPreviewPage };
+2
View File
@@ -26,6 +26,8 @@ export const ENDPOINT = {
SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans",
DASHBOARD: "/as-service/api/v1/dashboard",
DCRAG_TO: "/as-service/api/v1/dcrag-to",
PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng",
PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
-22
View File
@@ -1,23 +1 @@
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";
@@ -1,21 +0,0 @@
/**
* Demo 정적 파일을 부모 origin에서 fetch해 Blob으로 반환한다.
* iframe origin에서 URL을 직접 Open하면 CORS로 실패할 수 있어 Blob Open을 우선한다.
*/
async function fetchDemoBlob(demoBaseUrl: string, fileName: string): Promise<Blob> {
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 };
@@ -6,19 +6,24 @@ interface WindowWithNamedHwpFrame extends Window {
Document_HwpCtrl?: Window & HwpCtrlFrameWindow;
}
interface HwpCtrlRuntime extends HwpCtrl {
Impl?: unknown;
}
/**
* iframe ref 우선, 없으면 named frame 폴백으로 HwpCtrl을 반환한다.
* 로컬 `/webhwpctrl` 브리지는 same-origin이라 contentWindow 접근이 가능하다.
* BuildWebHwpCtrl 콜백 전에는 Impl이 없어 Open이 실패하므로, Impl이 붙은 뒤에만 반환한다.
*/
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;
if (isHwpCtrlInitialized(fromRef?.HwpCtrl)) return fromRef.HwpCtrl;
const namedFrame = (window as WindowWithNamedHwpFrame)[HWP_CTRL_IFRAME_NAME];
if (namedFrame?.HwpCtrl) return namedFrame.HwpCtrl;
if (isHwpCtrlInitialized(namedFrame?.HwpCtrl)) return namedFrame.HwpCtrl;
return null;
} catch {
@@ -26,4 +31,8 @@ function getHwpCtrl(iframe: HTMLIFrameElement | null): HwpCtrl | null {
}
}
function isHwpCtrlInitialized(ctrl: HwpCtrl | null | undefined): ctrl is HwpCtrl {
return Boolean(ctrl && (ctrl as HwpCtrlRuntime).Impl);
}
export { getHwpCtrl };
@@ -1,25 +1,7 @@
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";
@@ -2,47 +2,35 @@
export interface HwpCtrlCallbackResult {
result?: boolean | string | number;
message?: string;
data?: unknown;
[key: string]: unknown;
}
export type HwpCtrlCallback = (res: HwpCtrlCallbackResult) => void;
export interface HwpCtrlOpenCallbackResult {
result: boolean;
FileName?: string;
Size?: number;
CallbackUserData?: unknown;
message?: string;
[key: string]: unknown;
}
export type HwpCtrlOpenCallback = (result: HwpCtrlOpenCallbackResult) => void;
/**
* 샘플(v3_sample)에서 사용하는 HwpCtrl 메서드만 선언.
* 실무협의서 관리에서 사용하는 HwpCtrl 메서드만 선언.
* 실제 런타임 객체는 iframe contentWindow에 존재한다.
*/
export interface HwpCtrl {
Open: (
source: string | Blob | File,
source: File,
format: string,
option: string,
callback?: HwpCtrlCallback,
extra?: Record<string, unknown>,
) => 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;
callback?: HwpCtrlOpenCallback,
callbackUserData?: unknown,
) => boolean;
SaveAs: (
fileName: string,
format: string,
@@ -50,7 +38,6 @@ export interface HwpCtrl {
callback?: HwpCtrlCallback,
extra?: Record<string, unknown>,
) => void;
Clear: () => void;
}
export interface HwpCtrlFrameWindow {
@@ -1,116 +1,33 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, cn } from "@common/components";
import { Button } 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;
onDownload: () => void;
onFullscreen: () => void;
}
function HangulGiangiToolbar({
isApprovalMenuOpen,
isBusy = false,
onOpen,
onToggleApprovalMenu,
onCloseApprovalMenu,
onApproval1,
onApproval2,
onSign,
onSave,
onClear,
onPickLocalFile,
onDownload,
onFullscreen,
}: HangulGiangiToolbarProps) {
const approvalMenuRef = useRef<HTMLDivElement>(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 (
<div className="flex w-full flex-nowrap items-center gap-1 border-b border-border bg-background p-3">
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onOpen}>
열기
</Button>
<div ref={approvalMenuRef} className="relative">
<Button
type="button"
color="white"
size="sm"
disabled={isBusy}
onClick={(e) => {
e.stopPropagation();
onToggleApprovalMenu();
}}
>
결재 추가
</Button>
<div
className={cn(
"absolute left-0 top-full z-20 mt-0.5 min-w-full rounded-sm border border-border bg-background shadow-md",
isApprovalMenuOpen ? "block" : "hidden",
)}
>
<button
type="button"
color="white"
className="block w-full whitespace-nowrap px-3.5 py-1.5 text-left text-xs hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
onApproval1();
}}
>
1단
</button>
<button
type="button"
color="white"
className="block w-full whitespace-nowrap px-3.5 py-1.5 text-left text-xs hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
onApproval2();
}}
>
2단
</button>
</div>
</div>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onSign}>
결재
</Button>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onSave}>
HWP저장
저장
</Button>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onClear}>
문서 클리어
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onDownload}>
다운로드
</Button>
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onPickLocalFile}>
로컬 파일 열기
<Button type="button" color="white" size="sm" onClick={onFullscreen}>
전체화면
</Button>
</div>
);
}
export { HangulGiangiToolbar };
export type { HangulGiangiToolbarProps };
@@ -1,85 +1,57 @@
"use client";
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WEBHYP_CTRL_URL } from "@/shared/config";
import { cn } from "@common/components";
import { cn, ScrollArea, ScrollBar } 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,
documentBlob,
documentKey,
documentFileName,
onSaveDocument,
onDownload,
bridgeUrl = DEFAULT_BRIDGE_URL,
ctrlBaseUrl = WEBHYP_CTRL_URL,
demoBaseUrl = DEFAULT_DEMO_BASE_URL,
className,
onReady,
}: HangulGiangiProps) {
const resolvedBridgeUrl = bridgeUrl ?? ctrlUrl ?? DEFAULT_BRIDGE_URL;
const iframeRef = useRef<HTMLIFrameElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
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 [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<string | null>(null);
const [logs, setLogs] = useState<HangulGiangiLog[]>([]);
const iframeSrc = useMemo(
() => buildBridgeSrc(resolvedBridgeUrl, ctrlBaseUrl),
[resolvedBridgeUrl, ctrlBaseUrl],
);
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) {
@@ -88,15 +60,14 @@ function HangulGiangi({
if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return;
setIsCtrlReady(true);
setStatusMessage("기안기 초기화가 완료되었습니다.");
appendLog("기안기 초기화가 완료되었습니다.");
onReady?.();
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [onReady]);
}, [appendLog, onReady]);
// postMessage 누락 대비: HwpCtrl 존재 poll
useEffect(() => {
if (isCtrlReady) return;
@@ -104,166 +75,135 @@ function HangulGiangi({
const timer = window.setInterval(() => {
if (getHwpCtrl(iframeRef.current)) {
setIsCtrlReady(true);
setStatusMessage("기안기 초기화가 완료되었습니다.");
appendLog("기안기 초기화가 완료되었습니다.");
onReady?.();
window.clearInterval(timer);
return;
}
if (Date.now() - startedAt > 60_000) window.clearInterval(timer);
}, 500);
if (Date.now() - startedAt > HWP_CTRL_READY_TIMEOUT_MS) {
appendLog("웹한글 기안기 초기화 시간이 초과되었습니다.");
window.clearInterval(timer);
}
}, HWP_CTRL_POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [isCtrlReady, onReady]);
}, [appendLog, isCtrlReady, onReady]);
const handleOpen = useCallback(async () => {
const handleOpen = useCallback(() => {
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
if (!ctrl) return;
setIsBusy(true);
setStatusMessage(null);
appendLog("실무협의서를 불러오고 있습니다.");
openingDocumentKeyRef.current = documentKey;
attemptedDocumentKeyRef.current = documentKey;
try {
const blob = await fetchDemoBlob(demoBaseUrl, DEMO_FILES.report);
ctrl.Open(blob, "HWPX", "lock:false", (res) => {
console.log("Open 보고서.hwpx:", res);
const documentFile = new File([documentBlob], documentFileName, {
type: HWPX_CONTENT_TYPE,
});
setIsOpened(true);
setIsApprovalAdded(false);
setStatusMessage("문서를 열었습니다.");
} catch (error) {
console.error(error);
alert(
error instanceof Error
? error.message
: "문서 열기에 실패했습니다. Demo 파일 경로를 확인하세요.",
const isAccepted = ctrl.Open(
documentFile,
"HWPX",
"",
(result) => {
const callbackDocumentKey = getCallbackDocumentKey(result.CallbackUserData, documentKey);
if (openingDocumentKeyRef.current !== callbackDocumentKey) return;
openingDocumentKeyRef.current = null;
setIsBusy(false);
if (!result.result) {
appendLog(result.message ?? "문서를 열지 못했습니다.");
return;
}
openedDocumentKeyRef.current = callbackDocumentKey;
appendLog("실무협의서를 열었습니다.");
},
{ documentKey } satisfies OpenCallbackUserData,
);
} finally {
if (isAccepted) return;
openingDocumentKeyRef.current = null;
setIsBusy(false);
appendLog("웹한글 기안기가 문서 열기 요청을 접수하지 못했습니다.");
} catch (error) {
openingDocumentKeyRef.current = null;
setIsBusy(false);
appendLog(getErrorMessage(error, "문서를 열지 못했습니다."));
}
}, [demoBaseUrl, isCtrlReady]);
}, [appendLog, documentBlob, documentFileName, documentKey, 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("먼저 '결재 추가' 버튼을 눌러주세요.");
useEffect(() => {
if (
!isCtrlReady ||
openedDocumentKeyRef.current === documentKey ||
openingDocumentKeyRef.current === documentKey ||
attemptedDocumentKeyRef.current === documentKey
) {
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]);
handleOpen();
}, [documentKey, handleOpen, 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]);
setIsBusy(true);
appendLog("실무협의서를 저장하고 있습니다.");
try {
ctrl.SaveAs(documentFileName, "HWPX", "download:false", async (result) => {
try {
if (result.result === false) {
throw new Error(result.message ?? "기안기에서 문서를 추출하지 못했습니다.");
}
const handleClear = useCallback(() => {
const ctrl = resolveHwpCtrl(iframeRef.current, isCtrlReady);
if (!ctrl) return;
const documentBlob = decodeDocumentData(result.data);
if (!documentBlob) {
throw new Error("기안기 저장 결과에 문서 데이터가 없습니다.");
}
ctrl.Clear();
setIsOpened(false);
setIsApprovalAdded(false);
setIsApprovalMenuOpen(false);
setStatusMessage("문서를 클리어했습니다.");
}, [isCtrlReady]);
const handleLocalFileChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
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);
await onSaveDocument(documentBlob);
appendLog("실무협의서를 새 버전으로 저장했습니다.");
} catch (error) {
appendLog(getErrorMessage(error, "문서 저장에 실패했습니다."));
} finally {
setIsBusy(false);
}
});
} catch (error) {
setIsBusy(false);
appendLog(getErrorMessage(error, "문서 저장에 실패했습니다."));
}
}, [appendLog, documentFileName, isCtrlReady, onSaveDocument]);
setIsOpened(true);
setIsApprovalAdded(false);
setStatusMessage(`${file.name} 파일을 열었습니다.`);
event.target.value = "";
},
[isCtrlReady],
);
const handleDownload = useCallback(async () => {
setIsBusy(true);
appendLog("실무협의서를 다운로드하고 있습니다.");
try {
await onDownload();
appendLog("실무협의서를 다운로드했습니다.");
} catch (error) {
appendLog(getErrorMessage(error, "문서 다운로드에 실패했습니다."));
} finally {
setIsBusy(false);
}
}, [appendLog, onDownload]);
const isToolbarBusy = isBusy || !isCtrlReady;
const handleFullscreen = useCallback(async () => {
if (!iframeRef.current) {
appendLog("전체화면으로 표시할 웹한글 기안기를 찾지 못했습니다.");
return;
}
try {
await iframeRef.current.requestFullscreen();
appendLog("웹한글 기안기를 전체화면으로 표시했습니다.");
} catch (error) {
appendLog(getErrorMessage(error, "전체화면 전환에 실패했습니다."));
}
}, [appendLog]);
return (
<div
@@ -273,28 +213,34 @@ function HangulGiangi({
)}
>
<HangulGiangiToolbar
isApprovalMenuOpen={isApprovalMenuOpen}
isBusy={isToolbarBusy}
onOpen={handleOpen}
onToggleApprovalMenu={() => setIsApprovalMenuOpen((prev) => !prev)}
onCloseApprovalMenu={() => setIsApprovalMenuOpen(false)}
onApproval1={() => void insertApprovalTable(DEMO_FILES.approvalTable1)}
onApproval2={() => void insertApprovalTable(DEMO_FILES.approvalTable2)}
onSign={() => void handleSign()}
isBusy={isBusy || !isCtrlReady}
onSave={handleSave}
onClear={handleClear}
onPickLocalFile={() => fileInputRef.current?.click()}
onDownload={() => void handleDownload()}
onFullscreen={() => void handleFullscreen()}
/>
{statusMessage ? (
<p className="bg-muted/40 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
{statusMessage}
</p>
) : !isCtrlReady ? (
<p className="bg-muted/40 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
기안기 초기화 중…
</p>
) : null}
<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}
@@ -302,20 +248,103 @@ function HangulGiangi({
name={HWP_CTRL_IFRAME_NAME}
title="웹한글 기안기"
src={iframeSrc}
style={{ height: "700px", width: "calc(100vw-250px)" }}
className="border-0"
/>
<input
ref={fileInputRef}
type="file"
accept=".hwp,.hwpx,.hwt,.hwtx,.doc,.docx,.odt,.txt,.html"
className="invisible h-px w-px"
onChange={handleLocalFileChange}
className="fullscreen:h-screen fullscreen:min-h-0 fullscreen:w-screen h-[70vh] min-h-[40rem] w-full border-0 bg-background"
/>
</div>
);
}
function resolveHwpCtrl(iframe: HTMLIFrameElement | null, isCtrlReady: boolean): HwpCtrl | null {
const ctrl = getHwpCtrl(iframe);
if (ctrl) return ctrl;
alert(
isCtrlReady
? "웹한글 기안기 컨트롤을 찾지 못했습니다. 브리지 페이지를 확인하세요."
: "웹한글 기안기가 아직 초기화되지 않았습니다. 잠시 후 다시 시도하세요.",
);
return null;
}
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);
if (bridgeUrl.startsWith("/")) return `${url.pathname}${url.search}`;
return url.toString();
}
function decodeDocumentData(data: unknown): Blob | null {
if (data instanceof Blob) return data;
if (data instanceof ArrayBuffer) {
return new Blob([data], { type: HWPX_CONTENT_TYPE });
}
if (ArrayBuffer.isView(data)) {
const bytes = new Uint8Array(data.byteLength);
bytes.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
return new Blob([bytes], { type: HWPX_CONTENT_TYPE });
}
if (typeof data !== "string" || data.length === 0) return null;
const encodedData = data.includes(",") ? data.slice(data.indexOf(",") + 1) : data;
const binary = window.atob(encodedData);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
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" &&
callbackUserData !== null &&
"documentKey" in callbackUserData &&
typeof callbackUserData.documentKey === "string"
) {
return callbackUserData.documentKey;
}
return fallbackDocumentKey;
}
const HWP_CTRL_POLL_INTERVAL_MS = 500;
const HWP_CTRL_READY_TIMEOUT_MS = 60_000;
const HWPX_CONTENT_TYPE = "application/haansofthwpx";
interface OpenCallbackUserData {
documentKey: string;
}
interface HangulGiangiLog {
id: number;
timestamp: string;
message: string;
}
interface HangulGiangiProps {
documentBlob: Blob;
documentKey: string;
documentFileName: string;
onSaveDocument: (file: Blob) => Promise<void>;
onDownload: () => Promise<void>;
bridgeUrl?: string;
ctrlBaseUrl?: string;
className?: string;
onReady?: () => void;
}
export { HangulGiangi };
export type { HangulGiangiProps };