[as fr] 병합자 웹기안기 새창으로 SP, SF 참조 하도록 하기

This commit is contained in:
2026-10-23 14:03:46 +09:00
parent 175964b652
commit 56688eb1a6
8 changed files with 712 additions and 26 deletions
@@ -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;