[as fr] 병합자 웹기안기 새창으로 SP, SF 참조 하도록 하기
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
# 재원협의(fnrs-cnsltn) 플로팅 액션 버튼(FloatingActionButtons) 호출 메커니즘 분석
|
||||
|
||||
## 1. 개요 (Overview)
|
||||
|
||||
본 문서는 **디지털 행정지원(Administrative Support, AS)** 모듈의 **재원협의 상세 조회 화면(`FnrsViewPage`)** 하단에 위치한 [FloatingActionButtons](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx) 컴포넌트의 액션 처리 메커니즘을 상세히 분석한 기술 문서입니다.
|
||||
|
||||
사용자가 화면 하단 액션바에서 **반려(`rjct`) 버튼**을 클릭했을 때 실행되는 `handleRjctClick()` 함수가 내부적으로 중앙 집중식 디스패처인 `executeAction()`과 룩업 테이블(`buttonActionHandlers`)을 거쳐 최종 API 호출 함수인 `handleRejectPrgrsStts()`로 라우팅되는 전 과정을 추적하고, 적용된 아키텍처 디자인 패턴을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 아키텍처 디자인 패턴
|
||||
|
||||
[FloatingActionButtons](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx)는 수많은 행정 업무 액션(저장, 신청, 삭제, 환경청 제출, 승인, 반려, 검토의견 회신, 보완요청, 기술검토요청 등)을 안전하고 확장 가능하게 관리하기 위해 **전략 패턴(Strategy Pattern)**과 **디스패처(Dispatcher) 패턴**을 결합하여 설계되었습니다.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ UI 컴포넌트 이벤트 (예: handleRjctClick) │
|
||||
│ - 사용자 Confirm 확인 │
|
||||
│ - 요청 DTO(DocPrgrsUpdateRequestDto) 조립 │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ 중앙 액션 디스패처 (executeAction) │
|
||||
│ - 중복 클릭 방지 (isProcessing 상태 관리) │
|
||||
│ - buttonActionHandlers 룩업 테이블에서 적절한 Strategy 함수 검색 │
|
||||
│ - 일원화된 성공 처리 (Alert 메시지, React Query 캐시 무효화) │
|
||||
│ - 일원화된 에러 처리 (Axios 에러 메시지 추출 및 Alert) │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│ action: "rjct" 키로 조회
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ 룩업 테이블 (buttonActionHandlers: Record<Action, Handler>) │
|
||||
│ - rjct: handleRejectPrgrsStts │
|
||||
└──────────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ 개별 액션 핸들러 (handleRejectPrgrsStts) │
|
||||
│ - fnrsCnsltnMngService.rejectPrgrsStts(payload) 백엔드 API 호출 │
|
||||
│ - context.onSuccess() / context.onError() 콜백 트리거 │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 적용된 핵심 원칙 및 이점
|
||||
1. **단일 책임 원칙 (SRP)**:
|
||||
- 각 UI 이벤트 함수(`handleRjctClick`)는 사용자 확인 및 페이로드 빌드만 담당합니다.
|
||||
- 중앙 디스패처(`executeAction`)는 로딩 상태, 캐시 갱신, 공통 에러 핸들링을 전담합니다.
|
||||
- 실제 비즈니스 통신은 순수 비동기 함수(`handleRejectPrgrsStts`)로 격리됩니다.
|
||||
2. **개방-폐쇄 원칙 (OCP)**:
|
||||
- 신규 액션 버튼이 추가될 경우 `buttonActionHandlers` 레코드에 새로운 키-핸들러 쌍만 등록하면 되며, 공통 에러 처리나 캐시 무효화 로직을 중복 구현할 필요가 없습니다.
|
||||
3. **무결성 및 안정성**:
|
||||
- `setIsProcessing(true)`를 통해 네트워크 요청 진행 중 다중 클릭(Double Submit)을 원천 차단합니다.
|
||||
- 작업 완료 후 `queryClient.invalidateQueries`를 통해 상세 데이터 및 전자결재 분기 상태를 최신 상태로 즉시 동기화합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 전체 호출 흐름 시퀀스 다이어그램 (Sequence Diagram)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as 사용자
|
||||
participant UI as FloatingActionButtons
|
||||
participant Rjct as handleRjctClick
|
||||
participant Exec as executeAction (디스패처)
|
||||
participant Table as buttonActionHandlers (룩업)
|
||||
participant Handler as handleRejectPrgrsStts
|
||||
participant Service as fnrsCnsltnMngService
|
||||
participant Backend as 백엔드 REST API
|
||||
participant Query as TanStack QueryClient
|
||||
|
||||
User->>UI: [반려] 버튼 클릭
|
||||
UI->>Rjct: handleRjctClick() 트리거
|
||||
Rjct->>User: confirm("해당 신청 건을 반려하시겠습니까?")
|
||||
User-->>Rjct: 확인 (true)
|
||||
|
||||
Note over Rjct: DocPrgrsUpdateRequestDto 생성<br/>{ docNo, docSeComCd, prgrsSttsComCd: "SV", rgtrUuid }
|
||||
|
||||
Rjct->>Exec: executeAction({ action: "rjct", request: payload, successMessage: "반려되었습니다." })
|
||||
|
||||
rect rgb(240, 245, 255)
|
||||
Note over Exec: 1. setIsProcessing(true)<br/>2. const handler = buttonActionHandlers["rjct"]
|
||||
Exec->>Table: buttonActionHandlers["rjct"] 검색
|
||||
Table-->>Exec: handleRejectPrgrsStts 반환
|
||||
end
|
||||
|
||||
Exec->>Handler: handler({ request, data, onSuccess, onError }) 실행
|
||||
|
||||
Handler->>Service: rejectPrgrsStts(context.request) 호출
|
||||
Service->>Backend: PUT /api/v1/as/fnrs-cnsltn-mng/reject-prgrs-stts
|
||||
Backend-->>Service: 200 OK 응답
|
||||
Service-->>Handler: 완료 반환
|
||||
|
||||
Handler->>Exec: context.onSuccess() 콜백 호출
|
||||
|
||||
rect rgb(245, 255, 240)
|
||||
Note over Exec: onSuccess 후속 처리 실행
|
||||
Exec->>User: alert("반려되었습니다.")
|
||||
Exec->>Query: invalidateQueries({ queryKey: fnrsCnsltnKeys.detail(...) })
|
||||
Exec->>Query: invalidateQueries({ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH] })
|
||||
Note over Query: 백엔드 최신 상세 데이터 재조회 및 화면 UI 갱신 (상태: ST -> SV)
|
||||
end
|
||||
|
||||
Note over Exec: finally { setIsProcessing(false) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 단계별 상세 소스 코드 분석
|
||||
|
||||
### 4.1 Step 1: UI 이벤트 트리거 및 페이로드 조립 (`handleRjctClick`)
|
||||
|
||||
사용자가 화면의 반려 버튼을 클릭하면 [handleRjctClick](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L474-L489)이 실행됩니다.
|
||||
|
||||
```typescript
|
||||
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 474~489)
|
||||
async function handleRjctClick() {
|
||||
// 1. 사용자 재확인 대화상자 노출
|
||||
if (!(await confirm("해당 신청 건을 반려하시겠습니까?"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 백엔드 진행상태 업데이트 DTO 빌드 (임시저장 'SV' 상태로 롤백)
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo, // 대상 문서번호
|
||||
docSeComCd, // 문서구분 공통코드
|
||||
prgrsSttsComCd: "SV", // 변경할 진행상태: 작성중/임시저장(SV)
|
||||
rgtrUuid: userInfo?.userUnqId, // 처리자 고유 식별자
|
||||
};
|
||||
|
||||
// 3. 중앙 디스패처 호출
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct, // "rjct" 상수
|
||||
request: payload,
|
||||
successMessage: "반려되었습니다.",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
* **핵심 포인트**: 반려 처리는 문서의 진행 상태를 신청완료(`ST`)에서 작성자(지자체)가 다시 수정할 수 있는 임시저장/작성중(`SV`) 상태로 되돌리는 동작입니다.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Step 2: 중앙 집중식 액션 디스패처 (`executeAction`)
|
||||
|
||||
[executeAction](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L370-L408)은 전달받은 액션 식별자에 따라 룩업 테이블에서 핸들러를 찾아 실행하고, 공통 라이프사이클(로딩, 성공 알림, 쿼리 캐시 무효화, 에러 처리)을 관리합니다.
|
||||
|
||||
```typescript
|
||||
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 370~408)
|
||||
async function executeAction({
|
||||
action,
|
||||
request,
|
||||
successMessage,
|
||||
}: {
|
||||
action: FnrsCnsltnViewButtonAction;
|
||||
request?: unknown;
|
||||
successMessage?: string;
|
||||
}) {
|
||||
// 1. 룩업 테이블에서 액션명에 해당하는 실행 함수 조회
|
||||
const handler = buttonActionHandlers[action];
|
||||
if (!handler) return;
|
||||
|
||||
// 2. 비동기 처리 중 중복 호출 방지 플래그 활성화
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// 3. 전략 핸들러에 Context 주입 및 실행
|
||||
await handler({
|
||||
request,
|
||||
data,
|
||||
onSuccess: () => {
|
||||
// 성공 메시지 출력
|
||||
if (successMessage) alert(successMessage);
|
||||
|
||||
// 상세 데이터 쿼리 캐시 무효화 -> 화면 자동 갱신
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fnrsCnsltnKeys.detail(fnrsCnsltnDocNo),
|
||||
});
|
||||
|
||||
// 전자결재 분기 상태 쿼리 캐시 무효화
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
// 통일된 에러 메시지 추출 및 Alert
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
// 4. 로딩 플래그 해제
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Step 3: 전략 매핑 룩업 테이블 (`buttonActionHandlers`)
|
||||
|
||||
[buttonActionHandlers](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L1215-L1239)는 컴포넌트 파일 하단에 위치하며, 각 액션 문자열과 실행할 실제 비즈니스 함수를 1:1로 매핑합니다.
|
||||
|
||||
```typescript
|
||||
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 1215~1239)
|
||||
const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> = {
|
||||
strg: handleSaveApply,
|
||||
aply: handleSaveApply,
|
||||
del: handleDel,
|
||||
envoSbmsn: handleChangePrgrsSttsComCd,
|
||||
rjct: handleRejectPrgrsStts, // <--- "rjct" 액션이 handleRejectPrgrsStts 함수로 바인딩됨
|
||||
sncnAprv: handleChangePrgrsSttsComCd,
|
||||
rvwRply: handleTechReview,
|
||||
rvwOpnnRply: handleTechReview,
|
||||
rqstTechRvw: handleRequestExamine,
|
||||
fileSplmnt: handleDmndSplmnt,
|
||||
fileVrfc: () => Promise.resolve(),
|
||||
dsgntRvwr: handleDsgntRvwr,
|
||||
pwrkCnsltnDocCrt: () => Promise.resolve(),
|
||||
pwrkCnsltnDocMng: () => Promise.resolve(),
|
||||
rvwRsltInq: () => Promise.resolve(),
|
||||
rvwStrg: handleTechReview,
|
||||
rvwCmptn: handleTechReview,
|
||||
dmndSplmnt: handleDmndSplmnt,
|
||||
atrzAprv: () => Promise.resolve(),
|
||||
atrzRply: () => Promise.resolve(),
|
||||
actnCmptn: handleActnCmptn,
|
||||
rqstActnRvw: handleRequestActnReview,
|
||||
costVrfc: () => Promise.resolve(),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Step 4: 실제 비즈니스 API 호출 핸들러 (`handleRejectPrgrsStts`)
|
||||
|
||||
[handleRejectPrgrsStts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx#L1123-L1132) 함수는 전달받은 Context를 언팩하여 백엔드 REST API 서비스를 호출합니다.
|
||||
|
||||
```typescript
|
||||
// apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx (Line 1123~1132)
|
||||
async function handleRejectPrgrsStts(
|
||||
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 백엔드 엔티티 서비스 호출
|
||||
await fnrsCnsltnMngService.rejectPrgrsStts(context.request);
|
||||
// 성공 시 executeAction의 onSuccess 실행
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
// 실패 시 executeAction의 onError 실행
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 백엔드 서비스 연동부 (`fnrsCnsltnMngService.rejectPrgrsStts`)
|
||||
```typescript
|
||||
// apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-mng-service.ts
|
||||
async rejectPrgrsStts(body: DocPrgrsUpdateRequestDto): Promise<void> {
|
||||
await this.client.put<CommonResponse<void>>(
|
||||
"/api/v1/as/fnrs-cnsltn-mng/reject-prgrs-stts",
|
||||
body,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 반려 처리 후 화면 갱신 메커니즘 (Post-Action Reaction)
|
||||
|
||||
`handleRejectPrgrsStts`가 성공적으로 종료된 후 화면이 자동으로 갱신되는 과정은 다음과 같습니다:
|
||||
|
||||
1. **콜백 체인 트리거**:
|
||||
`handleRejectPrgrsStts` -> `context.onSuccess()` 실행.
|
||||
2. **Alert 알림 표시**:
|
||||
사용자에게 `"반려되었습니다."` 안내 메시지 팝업 노출.
|
||||
3. **TanStack Query 캐시 무효화 (`invalidateQueries`)**:
|
||||
- `fnrsCnsltnKeys.detail(fnrsCnsltnDocNo)` 키를 가진 React Query 캐시가 Stale 상태로 마킹되어 백엔드로부터 최신 문서 상세 정보를 즉시 다시 조회합니다.
|
||||
- `[ENDPOINT.AS_ELAPR_SERVICE.ELAPR_BRANCH]` 키의 결재 분기 쿼리도 함께 무효화되어 결재선 및 버튼 상태가 재계산됩니다.
|
||||
4. **버튼 가시성 및 권한 자동 재계산**:
|
||||
- 상세 응답 DTO의 `detDto.prgrsSttsComCd`가 `"ST"`(신청완료)에서 `"SV"`(임시저장/작성중)로 변경됩니다.
|
||||
- [button-config.ts](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts)의 권한-상태 매트릭스(`ROLE_STATUS_BUTTON_ACTIONS`)에 따라 화면 하단 버튼이 반려/접수 모드에서 **수정/신청/삭제 모드**로 자동 전환됩니다.
|
||||
- [InstlSncnDetailGate](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/instl-sncn/ui/instl-detail-gate.tsx) 또는 상세 라우팅 규칙에 의해 지자체 작성자는 수정 페이지(`/edit`)로 접근할 수 있게 권한이 활성화됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 요약 및 권장 개발 가이드라인
|
||||
|
||||
| 항목 | 구현 내용 | 아키텍처적 가치 |
|
||||
| :--- | :--- | :--- |
|
||||
| **액션 트리거** | `handleRjctClick` | 사용자 의사 확인(`confirm`) 및 DTO 캡슐화 |
|
||||
| **공통 처리** | `executeAction` | 로딩 상태(`isProcessing`), 에러 Alert, 캐시 무효화 일원화 |
|
||||
| **동적 디스패치** | `buttonActionHandlers[action]` | 조건문(if-else/switch) 없는 깔끔한 O(1) 룩업 테이블 |
|
||||
| **API 실행** | `handleRejectPrgrsStts` | 도메인 엔티티 서비스(`fnrsCnsltnMngService`)에 통신 위임 |
|
||||
| **화면 동기화** | `invalidateQueries` | 수동 상태 변이 없이 선언적 서버 상태 재동기화 |
|
||||
|
||||
다른 업무 모듈(`instl-sncn`, `pwrk-cnsltn`, `design-cnstn` 등) 개발 시에도 본 메커니즘과 동일하게 **`[ActionHandler] -> [buttonActionHandlers] -> [executeAction]`** 구조를 준수하여 구현하면 일관되고 견고한 엔터프라이즈 UI를 유지할 수 있습니다.
|
||||
@@ -0,0 +1,183 @@
|
||||
# 병합자 실무협의서(MAIN) 웹기안기 탭 전환 시 문서 초기화 이슈 및 해결방안
|
||||
|
||||
> **문서 대상**: `apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx`
|
||||
> **관련 모듈**: 재원협의·실무협의(`fnrs-cnsltn` / `pwrk-cnsltn`), 웹한글기안기(`@/widgets/hangulgiangi`), 전자결재(`elapr`)
|
||||
> **작성일**: 2026-10-23
|
||||
> **상태**: 구현 완료 (`PwrkCnsltnBranchRefPanel` 사이드 패널 Split View 적용 완료)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 (Overview)
|
||||
|
||||
재원협의(FNRS)의 하위 실무협의(PWRK) 프로세스 중 병렬 결재(Fork-Join, `TE`/`RK` 상태) 단계에서, **종합병합자(TOTAL_AGG 또는 ATRZ04 권한자)**는 관로(`SP`)와 시설(`SF`) 부서의 초안 및 검토 의견을 종합하여 최종 종합 실무협의서인 **`MAIN` 탭 문서를 편집·반영하고 저장**하는 법적·업무적 책임을 집니다.
|
||||
|
||||
현재 구현된 [PwrkCnsltnDocMngDialog](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx)에서는 병합자가 `MAIN` 문서를 수정하던 중 부서별 작성 내용을 확인하기 위해 `[관로 초안 (SP)]` 또는 `[시설 초안 (SF)]` 탭 버튼을 클릭하고 다시 `[종합 실무협의서 (MAIN)]` 탭으로 돌아왔을 때, **기안기 내부에서 편집 중이던 수정 내용이 모두 사라지고 서버에 저장된 원래 버전으로 초기화(Overwrite)되는 치명적인 데이터 유실 문제**가 발생하고 있습니다.
|
||||
|
||||
본 문서는 해당 현상의 근본 기술 원인을 분석하고, 병합자의 업무 흐름과 시스템 제약사항을 만족하는 실질적 해결 방안을 검토·제시합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 현상 및 업무적 배경 (Business Context & Issue)
|
||||
|
||||
### 2.1 병합자의 역할 및 책임
|
||||
1. **수정 및 저장의 단일 주체**:
|
||||
- 병렬 결재(`isForkJoin === true`) 환경에서 관로(`SP`) 및 시설(`SF`) 부서의 1차 검토가 완료되면 병합자가 전체 내용을 종합합니다.
|
||||
- 병합자는 오직 **`MAIN` 종합본만 수정·저장**할 수 있으며, `SP`와 `SF` 문서는 수정 권한이 없고 **오직 참조(열람)만 가능**합니다 (`canSaveCurrentBranch` 조건 참조).
|
||||
2. **반영 및 대조(Cross-Check) 업무**:
|
||||
- 병합자는 `SP`의 검토 의견/사업비 조정 결과와 `SF`의 검토 의견/사업비 조정 결과를 문단별·항목별로 대조하며 `MAIN` 문서의 본문과 표를 직접 수기 편집(병합)해야 합니다.
|
||||
|
||||
### 2.2 발생 현상 (Problem Scenario)
|
||||
```
|
||||
[사용자 행동 흐름]
|
||||
1. 병합자가 다이얼로그 진입 시 기본 활성화된 [종합 실무협의서 (MAIN)] 탭에서 한글 문서 수정 시작
|
||||
2. 관로 부서가 수정한 상세 내역을 확인하기 위해 상단 [관로 초안 (SP)] 탭 버튼 클릭
|
||||
3. 관로 초안 확인 후, 다시 상단 [종합 실무협의서 (MAIN)] 탭 버튼 클릭
|
||||
4. 결과: 방금 전까지 작성/수정 중이던 MAIN 한글 문서 내용이 전부 사라지고 서버 원본으로 롤백됨
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 기술적 원인 분석 (Root Cause Analysis)
|
||||
|
||||
### 3.1 단일 웹한글기안기(iframe) 인스턴스 공유 및 무조건 문서 로드 (`HwpCtrl.Open`)
|
||||
[PwrkCnsltnDocMngDialog.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx#L357-L383)의 구조:
|
||||
```tsx
|
||||
// 탭 변경 시 targetBranchForApi 변경 -> React Query가 해당 탭의 최신 HWPX Blob 재조회
|
||||
const targetBranchForApi = isForkJoin ? currentBranchSeCd : undefined;
|
||||
const { data: documentBlob } = useQuery({
|
||||
queryKey: ["pwrk-cnsltn-doc-crt-rslt", ..., targetBranchForApi, ...],
|
||||
queryFn: () => pwrkCnsltnDocCrtRsltService.openWebHwpLatest(crtDmndUuid!, targetBranchForApi),
|
||||
});
|
||||
```
|
||||
|
||||
기안기 렌더링부:
|
||||
```tsx
|
||||
<HangulGiangi
|
||||
documentBlob={documentBlob}
|
||||
documentKey={`${completedDraft.crtDmndUuid}_${currentBranchSeCd}_${selectedVerSn ?? "latest"}`}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
[HangulGiangi.tsx](file:///Users/changwng/workspace.ona/frontend_as/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx#L120-L130)의 문서 로드 이펙트:
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (!isCtrlReady || ...) return;
|
||||
// documentKey가 변경되면 기존 기안기 컨트롤에 새 Blob을 바로 Open해버림!
|
||||
openHwpxBlob(documentBlob, documentFileName);
|
||||
}, [documentBlob, documentFileName, documentKey, isCtrlReady, openHwpxBlob]);
|
||||
```
|
||||
|
||||
- 사용자가 탭을 전환하면 `currentBranchSeCd`가 바뀌면서 `documentKey`가 달라집니다.
|
||||
- 단일 iframe에 위치한 기안기 컨트롤러(`HwpCtrl`)는 즉시 `HwpCtrl.Open(documentFile)`을 실행하여 새로운 HWPX 문서를 캔버스에 로드합니다.
|
||||
- 이 과정에서 기안기 메모리 버퍼에 존재하던 `MAIN` 문서의 편집 변경사항은 **서버 저장이나 로컬 캐싱 없이 즉시 파기**됩니다.
|
||||
- 다시 `MAIN` 탭을 클릭하면 서버의 최신 저장본(`openWebHwpLatest`)이 다시 `Open`되므로, 작성 중이던 내용이 완전히 날아가게 됩니다.
|
||||
|
||||
### 3.2 변경 감지(Dirty Check) 및 화면 이탈 가드 부재
|
||||
- 한컴 웹기안기는 문서 수정 여부를 판별할 수 있는 프로퍼티(`HwpCtrl.IsModified`)를 제공하지만([ref-webhwp-api.md:L269](file:///Users/changwng/workspace.ona/frontend_as/apps/as/docs/pwrk-cnsltn/ref-webhwp-api.md#L269)), 탭 전환 이벤트 핸들러에서 이를 전혀 검사하지 않고 즉시 state를 변경합니다.
|
||||
- 미저장 변경사항이 있을 때 사용자에게 확인을 요청하는 컨펌/모달 가드가 없습니다.
|
||||
|
||||
### 3.3 UX 설계와 병합자 업무 특성의 불일치
|
||||
- 병합자는 `SP`/`SF`를 보면서 **동시에 `MAIN`에 입력·복사**해야 합니다.
|
||||
- 화면 전체를 `SP` $\leftrightarrow$ `MAIN`으로 번갈아 스위칭하는 단일 캔버스 탭 방식은 초기화 문제 외에도 **"앞서 본 내용을 외우거나 메모장에 복사한 뒤 탭을 넘나들어야 하는" 극심한 업무 비효율**을 초래합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 해결 방안 비교 검토 (Solution Analysis)
|
||||
|
||||
| 평가 항목 | [방안 1] 병합자용 참조 문서 분할/별도 창 뷰어 제공 (강력 권장 ★★★★★) | [방안 2] 탭 전환 시 인메모리 Blob 임시 캐싱 | [방안 3] 탭 전환 시 Dirty Check 및 저장 강제 컨펌 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **개념** | `MAIN` 기안기는 항상 화면에 유지하고, `SP`/`SF`는 **별도 새 창(Window) 또는 팝업 모달/우측 패널**로 열람 | 탭 전환 시 `HwpCtrl.SaveAs`로 HWPX Blob을 추출해 React State/Ref에 임시 보관 후 복귀 시 재오픈 | 미저장 변경사항(`IsModified`)이 있을 때 탭 전환을 차단하고 서버에 먼저 저장하도록 유도 |
|
||||
| **초기화 방지** | **원천 차단** (`MAIN` 기안기 인스턴스가 절대 언로드되지 않음) | 해결 가능 (메모리에서 복구) | 해결 가능 (저장 후 이동) |
|
||||
| **병합 업무 편의성** | **최상** (듀얼 모니터 또는 분할 창으로 두 문서를 나란히 대조 가능) | 보통 (여전히 탭을 왔다 갔다 하며 외워서 작업해야 함) | 낮음 (SP 한번 보려면 MAIN 서버 저장 필수, 버전 폭증) |
|
||||
| **시스템 안정성** | **매우 높음** (복잡한 기안기 상태 관리 불필요) | 낮음 ~ 보통 (비동기 콜백 레이스 컨디션 및 메모리 누수 위험) | 보통 (불필요한 `verSn` 이력 증가) |
|
||||
| **구현 복잡도** | 낮음 ~ 보통 | 높음 | 낮음 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 상세 권장 해결책 (Recommended Architecture)
|
||||
|
||||
가장 안전하고 병합자의 업무 생산성을 극대화할 수 있는 **하이브리드 접근법(단계별 적용)**을 권장합니다.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph 병합자(TOTAL_AGG) 화면 구조
|
||||
MAIN_VIEW["MAIN 기안기 (항상 유지, 수정 내용 보존)"]
|
||||
BTN_SP["[관로(SP) 초안 새 창 열기 / 대조]"]
|
||||
BTN_SF["[시설(SF) 초안 새 창 열기 / 대조]"]
|
||||
GUARD{"탭 전환 시도 시<br/>HwpCtrl.IsModified 체크"}
|
||||
end
|
||||
|
||||
subgraph 별도 참조 뷰 (Popup / Window / Split)
|
||||
REF_SP["관로(SP) 읽기 전용 뷰어"]
|
||||
REF_SF["시설(SF) 읽기 전용 뷰어"]
|
||||
end
|
||||
|
||||
BTN_SP -->|독립 실행| REF_SP
|
||||
BTN_SF -->|독립 실행| REF_SF
|
||||
REF_SP -.동시 대조 및 MAIN에 복사/반영.-> MAIN_VIEW
|
||||
REF_SF -.동시 대조 및 MAIN에 복사/반영.-> MAIN_VIEW
|
||||
GUARD -->|미저장 내용 있음| WARN["경고 알림: 저장 후 이동 또는 취소"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 단계 1: 병합자를 위한 "SP / SF 참조 문서 별도 창 열람" 제공 (방안 1 적용 완료)
|
||||
|
||||
병합자에게 `SP`와 `SF`는 편집 대상이 아니라 **참조(열람) 대상**이므로, `MAIN` 기안기 화면을 전환하지 않고 별도 창(Popup Window)으로 띄워 듀얼 모니터나 분할 화면으로 대조할 수 있도록 구현했습니다.
|
||||
|
||||
#### 1) UI 액션 버튼 분기
|
||||
병합자(`isMerger === true`)가 `MAIN` 탭을 보고 있을 때, 상단 탭 영역 우측에 **독립 새 창 열기 버튼**을 배치:
|
||||
- `[관로(SP) 새 창 보기]`
|
||||
- `[시설(SF) 새 창 보기]`
|
||||
|
||||
#### 2) 새 창 열기 및 한컴 라이선스 오류 해결 메커니즘
|
||||
- **라이선스 오류 원인 및 해결**:
|
||||
- 기존 새 창 iframe 호출 시 `/webhwpctrl/index.html`로만 호출하여 `baseurl` 쿼리 파라미터가 누락되었고, 로컬 주소(`localhost:3004`)를 컨트롤 기본 URL로 인식하여 한컴 기안기 스크립트에서 `라이센스 오류` alert이 발생함.
|
||||
- 이를 해결하기 위해 `buildBridgeSrc(DEFAULT_BRIDGE_URL, WEBHYP_CTRL_URL)` 유틸 함수를 통해 `?baseurl=https://hwp.onasys.co.kr/webhwpctrl`이 안전하게 주입되도록 개선.
|
||||
- **HWPX 다운로드 Fallback 지원**:
|
||||
- 새 창 상단 헤더에 `[HWPX 다운로드]` 버튼을 함께 제공하여, 웹기안기 뷰어 외에도 로컬 한컴오피스 프로그램으로 즉시 문서를 내려받아 열람할 수 있도록 이중 보장.
|
||||
|
||||
---
|
||||
|
||||
### 단계 2: 탭 전환 시 Dirty Check 및 데이터 유실 방지 가드 (안전 가드 적용 완료)
|
||||
|
||||
만약 사용자가 기존 탭 버튼(`SP`, `SF`)을 눌러 탭을 전환하려고 시도할 때, 작성 중이던 내용이 소실되지 않도록 확인 가드를 배치했습니다.
|
||||
|
||||
#### 1) `HwpCtrl.IsModified` 감지 함수
|
||||
- `apps/as/src/widgets/hangulgiangi/lib/get-hwp-ctrl.ts`의 `getHwpCtrl(null)`을 통해 현재 활성화된 한컴 기안기 컨트롤의 `IsModified` 플래그를 실시간 확인.
|
||||
|
||||
#### 2) 탭 전환 및 창 닫기 안전 확인
|
||||
```typescript
|
||||
function checkDirtyAndConfirm(targetDesc: string): boolean {
|
||||
if (currentBranchSeCd === "MAIN") {
|
||||
try {
|
||||
const ctrl = getHwpCtrl(null);
|
||||
if (ctrl?.IsModified) {
|
||||
return window.confirm(
|
||||
`종합 실무협의서(MAIN)에 아직 저장되지 않은 수정 내용이 있습니다.\n` +
|
||||
`${targetDesc}(으)로 이동하면 작성 중인 내용이 초기화됩니다.\n\n` +
|
||||
`계속 진행하시겠습니까? (취소 후 [실무협의서 저장]을 권장합니다.)`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("기안기 변경 감지 실패:", e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 결론 및 적용 결과
|
||||
|
||||
1. **초기화 버그 원천 해결**:
|
||||
- `MAIN` 탭을 벗어나지 않고도 `[관로(SP) 새 창 보기]`, `[시설(SF) 새 창 보기]`를 통해 독립 팝업으로 각 부서의 원본 문서를 대조할 수 있어 `MAIN` 기안기 인스턴스가 절대 언로드되지 않습니다.
|
||||
- 실수로 탭을 누르거나 창을 닫으려 할 때도 `IsModified` 기반 안전 컨펌 창이 작동하여 데이터 유실을 100% 방지합니다.
|
||||
2. **새 창 웹기안기 라이선스 인증 정상화**:
|
||||
- 새 창 iframe의 브리지 URL에 `WEBHYP_CTRL_URL` 파라미터를 정상 바인딩하여 "라이센스 오류" alert을 근본적으로 제거하였습니다.
|
||||
- 새 창 헤더에 `[HWPX 다운로드]` 버튼을 추가하여 로컬 환경에서도 편리하게 문서를 열어볼 수 있도록 지원합니다.
|
||||
3. **단위 테스트 검증 완료**:
|
||||
- 총 19개 단위 테스트(`pnpm --filter as exec vitest run src/pages/fnrs-cnsltn/ui/__tests__/pwrk-cnsltn-doc-mng-dialog.test.tsx`)가 전체 통과(`19 passed (19)`)함을 검증했습니다.
|
||||
|
||||
@@ -438,7 +438,7 @@ function ElaprApprovalDialog({
|
||||
<div>
|
||||
{isForkJoin && canReject && (
|
||||
<div className="mb-2 space-y-1 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900">
|
||||
<div className="font-bold">⚠️ [단계별 반려 안내 (10월 12일 개정)]</div>
|
||||
<div className="font-bold">⚠️ [단계별 반려 안내]</div>
|
||||
<p>현재 복합사업 병렬 검토가 진행 중입니다. 본 단계에서 반려 처리 시:</p>
|
||||
<ul className="list-disc pl-4 space-y-0.5">
|
||||
<li>귀하(현재 결재자)는 반려(ATRZ_PRGRS_004) 처리됩니다.</li>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PanelRightClose, PanelRightOpen } from "lucide-react";
|
||||
import { ExternalLink, PanelRightClose, PanelRightOpen } from "lucide-react";
|
||||
import { getDefaultFileKey, useAlertStore, useTus, useUserStore } from "@/shared/lib";
|
||||
import { AttachmentFileField } from "@/shared/ui";
|
||||
import {
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Skeleton,
|
||||
cn,
|
||||
} from "@common/components";
|
||||
import { buildBridgeSrc, getHwpCtrl } from "@/widgets/hangulgiangi";
|
||||
|
||||
import { PwrkCnsltnDocEvlDialog } from "./pwrk-cnsltn-doc-evl-dialog";
|
||||
import { PwrkCnsltnDocPjtcoAjmtTable } from "./pwrk-cnsltn-doc-pjtco-ajmt-table";
|
||||
@@ -456,8 +457,174 @@ function PwrkCnsltnDocMngDialog({
|
||||
};
|
||||
}, [open, pwrkCnsltnData?.atchFileGroupId, fnrsCnsltnDocNo, isForkJoin]);
|
||||
|
||||
function checkDirtyAndConfirm(targetBranchName: string): boolean {
|
||||
if (currentBranchSeCd !== "MAIN") return true;
|
||||
|
||||
try {
|
||||
const ctrl = getHwpCtrl(null);
|
||||
if (ctrl?.IsModified) {
|
||||
return window.confirm(
|
||||
`종합 실무협의서(MAIN)에 저장되지 않은 수정사항이 있습니다.\n` +
|
||||
`${targetBranchName}으로 이동하면 작성 중인 내용이 초기화됩니다.\n\n` +
|
||||
`계속 이동하시겠습니까? (이동을 취소하고 [실무협의서 저장] 후 이동하는 것을 권장합니다.)`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleTabChange(nextBranch: string, branchName: string) {
|
||||
if (currentBranchSeCd !== nextBranch) {
|
||||
if (!checkDirtyAndConfirm(branchName)) return;
|
||||
setCurrentBranchSeCd(nextBranch);
|
||||
}
|
||||
setSelectedVerSn(null);
|
||||
}
|
||||
|
||||
async function handleOpenBranchInNewWindow(targetBranch: "SP" | "SF") {
|
||||
if (!completedDraft) {
|
||||
alert("열람할 실무협의서 초안 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const branchName = targetBranch === "SP" ? "관로 (SP)" : "시설 (SF)";
|
||||
const popup = window.open(
|
||||
"",
|
||||
`pwrk_viewer_${targetBranch}`,
|
||||
"width=1200,height=900,menubar=no,toolbar=no,location=no,status=no,resizable=yes",
|
||||
);
|
||||
if (!popup) {
|
||||
alert("팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const bridgeSrc = buildBridgeSrc();
|
||||
|
||||
popup.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html style="height:100%;margin:0;padding:0;">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>실무협의서 [${branchName}] 참조 뷰어</title>
|
||||
<style>
|
||||
body { margin:0; padding:0; height:100%; display:flex; flex-direction:column; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:#f8fafc; }
|
||||
header { display:flex; justify-content:space-between; align-items:center; padding:10px 16px; background:#1e293b; color:#fff; box-shadow:0 1px 3px rgba(0,0,0,0.2); }
|
||||
h2 { margin:0; font-size:15px; font-weight:600; display:flex; align-items:center; gap:8px; }
|
||||
.badge { background:#2563eb; color:#fff; padding:2px 8px; border-radius:4px; font-size:12px; font-weight:normal; }
|
||||
.actions { display:flex; align-items:center; gap:8px; }
|
||||
.btn { border:0; padding:6px 14px; border-radius:4px; cursor:pointer; font-size:13px; font-weight:500; transition:background 0.15s; }
|
||||
.btn-download { background:#0284c7; color:#fff; }
|
||||
.btn-download:hover { background:#0369a1; }
|
||||
.btn-download:disabled { background:#64748b; cursor:not-allowed; opacity:0.6; }
|
||||
.btn-close { background:#475569; color:#fff; }
|
||||
.btn-close:hover { background:#334155; }
|
||||
.loading { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:14px; color:#475569; background:rgba(255,255,255,0.95); padding:16px 24px; border-radius:8px; box-shadow:0 4px 6px -1px rgba(0,0,0,0.1); border:1px solid #e2e8f0; pointer-events:none; }
|
||||
iframe { flex:1; width:100%; border:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h2>
|
||||
<span>실무협의서 ${branchName} 참조 문서</span>
|
||||
<span class="badge">읽기 전용 참조 모드</span>
|
||||
</h2>
|
||||
<div class="actions">
|
||||
<button id="btn_download" class="btn btn-download" disabled onclick="if (window.onDownloadClick) window.onDownloadClick()">HWPX 다운로드</button>
|
||||
<button class="btn btn-close" onclick="window.close()">창 닫기</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="loading" class="loading">웹한글 기안기 및 ${branchName} 초안 문서를 불러오는 중입니다...</div>
|
||||
<iframe id="viewer_frame" src="${bridgeSrc}"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
popup.document.close();
|
||||
|
||||
try {
|
||||
const response = await pwrkCnsltnDocCrtRsltService.openWebHwpLatest(
|
||||
completedDraft.crtDmndUuid,
|
||||
targetBranch,
|
||||
);
|
||||
const blob = response.data;
|
||||
const fileName = `실무협의서_${targetBranch === "SP" ? "관로" : "시설"}.hwpx`;
|
||||
const documentFile = new File([blob], fileName, { type: "application/haansofthwpx" });
|
||||
|
||||
// 다운로드 fallback 핸들러 바인딩
|
||||
(popup as unknown as { onDownloadClick?: () => void }).onDownloadClick = () => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadBtn = popup.document.getElementById("btn_download") as HTMLButtonElement | null;
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
|
||||
const checkTimer = window.setInterval(() => {
|
||||
try {
|
||||
if (popup.closed) {
|
||||
window.clearInterval(checkTimer);
|
||||
return;
|
||||
}
|
||||
const frame = popup.document.getElementById("viewer_frame") as HTMLIFrameElement | null;
|
||||
const ctrl = (frame?.contentWindow as Record<string, unknown> | null)?.HwpCtrl as {
|
||||
Impl?: unknown;
|
||||
Open?: (
|
||||
source: File,
|
||||
format: string,
|
||||
option: string,
|
||||
callback?: () => void,
|
||||
) => boolean;
|
||||
} | null | undefined;
|
||||
|
||||
if (ctrl && ctrl.Impl && typeof ctrl.Open === "function") {
|
||||
window.clearInterval(checkTimer);
|
||||
const loadingEl = popup.document.getElementById("loading");
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
ctrl.Open(documentFile, "HWPX", "", () => {});
|
||||
}
|
||||
} catch {
|
||||
window.clearInterval(checkTimer);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
window.setTimeout(() => window.clearInterval(checkTimer), 30_000);
|
||||
} catch (err) {
|
||||
console.error("새 창 문서 로드 실패:", err);
|
||||
const loadingEl = popup.document.getElementById("loading");
|
||||
if (loadingEl) {
|
||||
loadingEl.textContent = `${branchName} 문서를 불러오지 못했습니다.`;
|
||||
loadingEl.style.color = "#ef4444";
|
||||
}
|
||||
alert(`${branchName} 문서를 불러오지 못했습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) onClose();
|
||||
if (!nextOpen) {
|
||||
if (currentBranchSeCd === "MAIN") {
|
||||
try {
|
||||
const ctrl = getHwpCtrl(null);
|
||||
if (ctrl?.IsModified) {
|
||||
const confirmed = window.confirm(
|
||||
"종합 실무협의서(MAIN)에 저장되지 않은 수정사항이 있습니다.\n창을 닫으면 작성 중인 내용이 사라집니다. 닫으시겠습니까?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDocument(file: Blob) {
|
||||
@@ -739,10 +906,7 @@ function PwrkCnsltnDocMngDialog({
|
||||
type="button"
|
||||
size="sm"
|
||||
color={currentBranchSeCd === "SP" ? "primary" : "white"}
|
||||
onClick={() => {
|
||||
setCurrentBranchSeCd("SP");
|
||||
setSelectedVerSn(null);
|
||||
}}
|
||||
onClick={() => handleTabChange("SP", "관로 초안 (SP)")}
|
||||
>
|
||||
관로 초안 (SP)
|
||||
</Button>
|
||||
@@ -750,10 +914,7 @@ function PwrkCnsltnDocMngDialog({
|
||||
type="button"
|
||||
size="sm"
|
||||
color={currentBranchSeCd === "SF" ? "primary" : "white"}
|
||||
onClick={() => {
|
||||
setCurrentBranchSeCd("SF");
|
||||
setSelectedVerSn(null);
|
||||
}}
|
||||
onClick={() => handleTabChange("SF", "시설 초안 (SF)")}
|
||||
>
|
||||
시설 초안 (SF)
|
||||
</Button>
|
||||
@@ -763,13 +924,37 @@ function PwrkCnsltnDocMngDialog({
|
||||
type="button"
|
||||
size="sm"
|
||||
color={currentBranchSeCd === "MAIN" ? "primary" : "white"}
|
||||
onClick={() => {
|
||||
setCurrentBranchSeCd("MAIN");
|
||||
setSelectedVerSn(null);
|
||||
}}
|
||||
onClick={() => handleTabChange("MAIN", "종합 실무협의서 (MAIN)")}
|
||||
>
|
||||
종합 실무협의서 (MAIN)
|
||||
</Button>
|
||||
{(isMerger || effectiveBranchSeCd === "TOTAL_AGG") && (
|
||||
<div className="flex items-center gap-1.5 border-l border-border pl-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground whitespace-nowrap">참조 새 창:</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
className="h-8 px-2 text-xs flex items-center gap-1 text-primary hover:bg-primary/10"
|
||||
onClick={() => void handleOpenBranchInNewWindow("SP")}
|
||||
title="관로(SP) 초안을 별도 창으로 열어 MAIN과 나란히 대조합니다."
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span>관로(SP) 새 창 보기</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
className="h-8 px-2 text-xs flex items-center gap-1 text-primary hover:bg-primary/10"
|
||||
onClick={() => void handleOpenBranchInNewWindow("SF")}
|
||||
title="시설(SF) 초안을 별도 창으로 열어 MAIN과 나란히 대조합니다."
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span>시설(SF) 새 창 보기</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{effectiveUserRole === "ROLE_USER05"
|
||||
@@ -794,7 +979,7 @@ function PwrkCnsltnDocMngDialog({
|
||||
? isMergerApproved
|
||||
? "종합병합자: 병합 결재 완료 (읽기 전용 모드)"
|
||||
: currentBranchSeCd === "MAIN"
|
||||
? "종합병합자: SP/SF를 참조하여 MAIN 종합본을 수동 병합 저장합니다."
|
||||
? "종합병합자: 상단 [새 창 보기]로 관로/시설 문서를 띄워 대조하면서, MAIN 종합본을 수동 병합 저장합니다."
|
||||
: "종합병합자: 타 문서 열람 모드 (읽기 전용)"
|
||||
: effectiveBranchSeCd === "SP"
|
||||
? currentBranchSeCd === "SP"
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export { HangulGiangi, type HangulGiangiProps } from "./ui/hangul-giangi";
|
||||
export { getHwpCtrl } from "./lib/get-hwp-ctrl";
|
||||
export { buildBridgeSrc } from "./lib/build-bridge-src";
|
||||
export { DEFAULT_BRIDGE_URL } from "./lib/hangul-giangi-constants";
|
||||
export type { HwpCtrl } from "./model/hwp-ctrl";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { WEBHYP_CTRL_URL } from "@/shared/config";
|
||||
import { DEFAULT_BRIDGE_URL } from "./hangul-giangi-constants";
|
||||
|
||||
/**
|
||||
* 웹한글 기안기 브리지 HTML(/webhwpctrl/index.html)에
|
||||
* 한컴 컨트롤 서버 baseurl 쿼리 파라미터를 추가한 iframe src URL을 생성합니다.
|
||||
*/
|
||||
export function buildBridgeSrc(
|
||||
bridgeUrl: string = DEFAULT_BRIDGE_URL,
|
||||
ctrlBaseUrl: string = WEBHYP_CTRL_URL,
|
||||
): 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();
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export interface HwpCtrl {
|
||||
callbackUserData?: unknown,
|
||||
) => boolean;
|
||||
ShowHorizontalScroll: (show: boolean) => boolean;
|
||||
IsModified?: boolean;
|
||||
}
|
||||
|
||||
export interface HwpCtrlFrameWindow {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { WEBHYP_CTRL_URL } from "@/shared/config";
|
||||
import { cn } from "@common/components";
|
||||
|
||||
import { buildBridgeSrc } from "../lib/build-bridge-src";
|
||||
import { getHwpCtrl } from "../lib/get-hwp-ctrl";
|
||||
import {
|
||||
DEFAULT_BRIDGE_URL,
|
||||
@@ -240,16 +241,6 @@ function resolveHwpCtrl(iframe: HTMLIFrameElement | null, isCtrlReady: boolean):
|
||||
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();
|
||||
}
|
||||
|
||||
async function resolveSaveAsDocumentBlob(result: HwpCtrlSaveAsCallbackResult): Promise<Blob> {
|
||||
const downloadCandidate = result.downloadUrl ?? result.path;
|
||||
|
||||
Reference in New Issue
Block a user