[as] feat: 실무협의서 웹기안기에 렌더링
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`은 클라이언트가 보내지 않음.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
+18
@@ -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>;
|
||||
}
|
||||
+9
@@ -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 +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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user