diff --git a/apps/as/docs/pm2/11.fnrs-cnsltn-floating-action-button-mechanism.md b/apps/as/docs/pm2/11.fnrs-cnsltn-floating-action-button-mechanism.md new file mode 100644 index 000000000..63c8e9df1 --- /dev/null +++ b/apps/as/docs/pm2/11.fnrs-cnsltn-floating-action-button-mechanism.md @@ -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) │ +│ - 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 생성
{ docNo, docSeComCd, prgrsSttsComCd: "SV", rgtrUuid } + + Rjct->>Exec: executeAction({ action: "rjct", request: payload, successMessage: "반려되었습니다." }) + + rect rgb(240, 245, 255) + Note over Exec: 1. setIsProcessing(true)
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 = { + 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, +): Promise { + 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 { + await this.client.put>( + "/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를 유지할 수 있습니다. diff --git a/apps/as/docs/pwrk-cnsltn/11.병합자_웹기안기_문서초기화_이슈_및_해결방안.md b/apps/as/docs/pwrk-cnsltn/11.병합자_웹기안기_문서초기화_이슈_및_해결방안.md new file mode 100644 index 000000000..9119b4d8d --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/11.병합자_웹기안기_문서초기화_이슈_및_해결방안.md @@ -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.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{"탭 전환 시도 시
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)`)함을 검증했습니다. + diff --git a/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx b/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx index 26d896267..9be7da1bd 100644 --- a/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx +++ b/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx @@ -438,7 +438,7 @@ function ElaprApprovalDialog({
{isForkJoin && canReject && (
-
⚠️ [단계별 반려 안내 (10월 12일 개정)]
+
⚠️ [단계별 반려 안내]

현재 복합사업 병렬 검토가 진행 중입니다. 본 단계에서 반려 처리 시:

  • 귀하(현재 결재자)는 반려(ATRZ_PRGRS_004) 처리됩니다.
  • diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx index 0513f09ce..e2f438495 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx @@ -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(` + + + + + 실무협의서 [${branchName}] 참조 뷰어 + + + +
    +

    + 실무협의서 ${branchName} 참조 문서 + 읽기 전용 참조 모드 +

    +
    + + +
    +
    +
    웹한글 기안기 및 ${branchName} 초안 문서를 불러오는 중입니다...
    + + + + `); + 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 | 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) @@ -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) @@ -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) + {(isMerger || effectiveBranchSeCd === "TOTAL_AGG") && ( +
    + 참조 새 창: + + +
    + )}
{effectiveUserRole === "ROLE_USER05" @@ -794,7 +979,7 @@ function PwrkCnsltnDocMngDialog({ ? isMergerApproved ? "종합병합자: 병합 결재 완료 (읽기 전용 모드)" : currentBranchSeCd === "MAIN" - ? "종합병합자: SP/SF를 참조하여 MAIN 종합본을 수동 병합 저장합니다." + ? "종합병합자: 상단 [새 창 보기]로 관로/시설 문서를 띄워 대조하면서, MAIN 종합본을 수동 병합 저장합니다." : "종합병합자: 타 문서 열람 모드 (읽기 전용)" : effectiveBranchSeCd === "SP" ? currentBranchSeCd === "SP" diff --git a/apps/as/src/widgets/hangulgiangi/index.ts b/apps/as/src/widgets/hangulgiangi/index.ts index cbce6f73a..089cad5f6 100644 --- a/apps/as/src/widgets/hangulgiangi/index.ts +++ b/apps/as/src/widgets/hangulgiangi/index.ts @@ -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"; diff --git a/apps/as/src/widgets/hangulgiangi/lib/build-bridge-src.ts b/apps/as/src/widgets/hangulgiangi/lib/build-bridge-src.ts new file mode 100644 index 000000000..9039c6fbb --- /dev/null +++ b/apps/as/src/widgets/hangulgiangi/lib/build-bridge-src.ts @@ -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(); +} diff --git a/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts index bc3ac97cd..90cc8c1c0 100644 --- a/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts +++ b/apps/as/src/widgets/hangulgiangi/model/hwp-ctrl.ts @@ -59,6 +59,7 @@ export interface HwpCtrl { callbackUserData?: unknown, ) => boolean; ShowHorizontalScroll: (show: boolean) => boolean; + IsModified?: boolean; } export interface HwpCtrlFrameWindow { diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx index 4fa2b8c3e..65bcf9fd6 100644 --- a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx @@ -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 { const downloadCandidate = result.downloadUrl ?? result.path;