diff --git a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md new file mode 100644 index 000000000..c90dec22f --- /dev/null +++ b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md @@ -0,0 +1,367 @@ +# 재원협의(fnrs-cnsltn) vs 설치인가(instl-sncn) 차이 분석 + +| 항목 | 내용 | +| --------- | ----------------------------------------------------------------------------- | +| 작성일 | 2026-07-09 | +| 기준 구현 | `fnrs-cnsltn` (추가 요구사항 반영 완료) | +| 비교 대상 | `instl-sncn` | +| 목적 | 두 페이지의 동작·구조 차이를 정리하고, 설치인가 정렬 시 이식 대상을 명확히 함 | + +--- + +## 1. 요약 + +두 모듈은 **동일한 PBADMS(공공행정 지원) 업무 패턴**을 따르는 형제 페이지다. + +공통 흐름: + +``` +목록 → TaskSe 선택 후 신청 → 저장(SV) → 신청완료(ST) + → 환경청제출(LE) → 기술검토 → 검토결과 회신(EP) + → 인가승인(TA) → 고시(TB) +``` + +| 구분 | fnrs-cnsltn | instl-sncn | +| ------------- | ----------------- | ---------------- | +| 업무명 | 재원협의 | 설치인가 | +| 문서번호 | `fnrsCnsltnDocNo` | `instlSncnDocNo` | +| docSeComCd | `FNRS` | `INSTL` | +| 라우트 | `/fnrs-cnsltn/*` | `/instl-sncn/*` | +| pages 파일 수 | 39 | 32 | + +**결론:** `fnrs-cnsltn`이 추가 요구사항이 반영된 기준(reference) 구현이고, `instl-sncn`은 동일 골격이지만 보완요청·결재선 편집·등록/수정 폼 완성도 등에서 미반영·TODO·단순화 상태다. + +--- + +## 2. 파일/폴더 구조 + +### 2-1. fnrs-cnsltn에만 존재 + +| 경로 | 역할 | +| -------------------------------------------- | ------------------------- | +| `ui/sections/fnrs-shar-section.tsx` | 재원분담 조회 섹션 | +| `ui/sections/supplement-demand-section.tsx` | 보완요청 이력 섹션 | +| `ui/components/sortable-prgrs-stts-row.tsx` | 결재선 DnD 편집 행 | +| `ui/components/sortable-apln-picker-row.tsx` | 결재선 템플릿 피커 DnD 행 | +| `ui/components/aprvr-picker-dialog.tsx` | 결재자 선택 다이얼로그 | +| `ui/components/elapr-apln-picker-grid.tsx` | 결재선 편집 그리드 | +| `ui/components/elapr-tmplt-select-table.tsx` | 결재서식 선택 테이블 | +| `model/reviewer-option.ts` | 결재자 옵션 타입 | + +### 2-2. instl-sncn에만 존재 + +| 경로 | 역할 | 비고 | +| -------------------------------------- | ------------------- | ------------------------------------------ | +| `ui/sections/history-info-section.tsx` | 등록/수정 이력 표시 | **어디에서도 import되지 않음 (dead code)** | + +### 2-3. 공통 구조 + +``` +index.ts +ui/ + {list,view,form,regist,edit}-page.tsx + sections/ (basic-info, contact, scale, cost, checklist, attachments, tech-review) + components/ (floating-action-buttons, elapr-approval-dialog, elapr-tmplt-picker-dialog, checklist-task-panel) +lib/ button-config, save-payload-utils, form-data-mapper, regist-schema, checklist/review utils +model/ columns.tsx, view-types.ts +``` + +App Router도 동일: `page.tsx`, `regist/page.tsx`, `[id]/page.tsx`, `[id]/edit/page.tsx` + +--- + +## 3. 목록 페이지 + +### 3-1. 검색 필터 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------------ | ----------------------------------------- | --------------------------------- | +| 시도/시군구 API | `@/entities/adstdstr` | 동일 | +| 지역 검색 파라미터 | **`admdstCd`** (시군구 우선, 없으면 시도) | **`sidoComCd` + `sggComCd`** 분리 | +| "전체" 옵션 | `ALL_SELECT_VALUE = "__ALL__"` SelectItem | 없음 (placeholder만) | +| 구분 CodeSelect | `upCd="INSTL"` ※ | `upCd="INSTL"` | +| 사업명 | 동일 | 동일 | + +> ※ fnrs 목록의 구분 필터가 `INSTL` 코드를 사용 중이며, TaskSePickerDialog는 `codeUpCd="FNRS"`를 쓴다. **불일치 가능성**이 있어 확인이 필요하다. + +### 3-2. 정렬·페이지네이션 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------------------ | ---------------------- | ------------------------------------- | +| 기본 sort (list-page) | `fnrsCnsltnDocNo,desc` | `instlSncnDocNo,desc` | +| fallback sort (list.tsx) | `fnrsCnsltnDocNo,desc` | **`regDt,desc`** (list-page와 불일치) | +| 페이지 크기 | 10 | 10 | +| URL 동기화 | 없음 (`useState` 로컬) | 없음 | + +### 3-3. 테이블 컬럼 + +`model/columns.tsx` 로직은 사실상 동일하다. DTO 타입명만 다르다. + +컬럼: 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 + +### 3-4. 행 클릭·신청 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------ | ------------------------------------------------------------------ | ------------------ | +| 행 클릭 | **조건부 라우팅**: `SV`/`SL` + 등록자 본인 → `/edit`, 그 외 → view | **항상 view** | +| useUserStore | 사용 | 미사용 | +| 신청 버튼 | TaskSePickerDialog `codeUpCd="FNRS"` | `codeUpCd="INSTL"` | + +```46:54:apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx + const handleRowClick = (row: FnrsCnsltnMngResponseDto) => { + const canEdit = + (row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") && + row.rgtr === userInfo?.userUnqId; + + router.push( + canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`, + ); + }; +``` + +--- + +## 4. 상세(View) 페이지 + +### 4-1. 섹션 구성 + +| 섹션 | fnrs-cnsltn | instl-sncn | +| ------------------------------- | ------------------------------------------ | -------------------------- | +| BasicInfo | 환경청구분, 확정예산사업번호, 예산신청연도 | **사업구분(`sncnSeCdNm`)** | +| ContactInfo | 동일 패턴 | 동일 | +| BusinessScale / Cost | 동일 | 동일 | +| **FnrsShar (재원분담)** | **있음** | **없음** (도메인 고유) | +| Checklist | 동일 | 동일 | +| Attachments | 동일 | 동일 | +| **SupplementDemand (보완요청)** | **있음** | **없음** | +| TechReviewOpinion | 동일 | 동일 | +| HistoryInfo | 없음 | 파일만 존재, view 미연결 | +| FloatingActionButtons | `docSeComCd="FNRS"` | `docSeComCd="INSTL"` | + +### 4-2. 데이터 훅 + +| 항목 | fnrs-cnsltn | instl-sncn | +| --------- | ---------------------------- | -------------------- | +| 상세 훅 | `useFnrsCnsltnMng` | `useInstlSncnDetail` | +| 에러 판정 | `isError \|\| !data?.detDto` | `isError \|\| !data` | +| 로딩 | Skeleton | Skeleton | + +--- + +## 5. 등록/수정(Form) 페이지 + +### 5-1. 폼 완성도 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------- | --------------------------------- | --------------- | +| 저장(SV) | O | O (SV 고정) | +| 신청완료(ST) | O (edit 모드) | **없음** | +| 삭제 | O (edit 모드, `useConfirm`) | **없음** | +| 재원분담 섹션 | `FnrsSharFormSection` + 잔액 검증 | 없음 | +| 저장 후 이동 | **`/edit` 유지** | **view로 이동** | +| 목록 버튼 | `/fnrs-cnsltn` 고정 | `router.back()` | + +### 5-2. 기본정보 Form 필드 + +| 필드 | fnrs | instl | +| ----------------- | ---------------- | --------------------------------------- | +| taskSe 코드 | `FNRS` | `INSTL` | +| 사업구분 | 없음 | **`sncnSeComCd` (SNCNSE 라디오, 필수)** | +| BnsPicker 연동 | bizNm ~ bizCn | + **`bgtBizCd`, `bgtAplyYr`** | +| 재이용(RE) 조건부 | rtlztnFclt / Usg | 동일 | + +### 5-3. Schema + +| 항목 | fnrs | instl | +| ------------------- | ------------------- | -------------- | +| 관리 객체 | `fnrsCnsltnMng` | `instlSncnMng` | +| 예산 필드 | `cfmtnBgtBizNo` | `bgtBizCd` | +| 재원분담 | `fnrsSharList` 배열 | 없음 | +| sharAtchFileGroupId | 있음 | 없음 | +| privacyConsent | 있음 | 있음 | + +### 5-4. Edit 페이지 래퍼 + +| 항목 | fnrs | instl | +| ------------------ | ------------------------------- | ------------------------------- | +| 로딩 Skeleton | 있음 | **없음** | +| 데이터 없음 메시지 | "상세 정보를 찾을 수 없습니다." | "수정 대상을 찾을 수 없습니다." | + +--- + +## 6. FloatingActionButtons / 결재 / 보완 + +### 6-1. FAB 동작 차이 + +공통: 역할×상태 기반 버튼(`button-config.ts`), checklist 진행률 검증, elapr pending approval 체크 + +| 기능 | fnrs-cnsltn | instl-sncn | +| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | +| 보완완료 (`splmntCmptn`) | **`submitSupplement` API 연동** | **`// TODO` stub** | +| 보완요청 (`dmndSplmnt`) | **`buildSupplementReqPayload` + `submitReqSupplement`** | **`// TODO` stub** (문자열만 전달) | +| 검토완료 (`rvwCmptn`) | `techReViewSaveCd: "SV"` 후 **결재 다이얼로그 오픈** | `techReViewSaveCd: "CP"`, **다이얼로그 없이 즉시 완료** | +| 검토자지정 | `buildElaprPrgrsSttsListFromAplnRows(aplnRows)` | `buildElaprPrgrsSttsListFromApln(aplnPage.content)` | +| atrzAprv / atrzRply / grndsChck | TODO handler | TODO handler | + +```576:617:apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx +async function handleSplmntCmptn(context: ActionHandlerContext): Promise { + // TODO: 보완완료 API 호출 + context.onSuccess?.(); +} +// ... +async function handleDmndSplmnt(context: ActionHandlerContext): Promise { + // TODO: 보완요청 API 호출 + context.onSuccess?.(); +} +``` + +### 6-2. ElaprApprovalDialog (가장 큰 UX 격차) + +| 항목 | fnrs (419줄) | instl (231줄) | +| ---------------- | ------------------------------------------------------ | -------------------------------- | +| 결재선 표시 | DnD sortable 편집 테이블 | **읽기 전용** 테이블 | +| 결재선 추가/저장 | **지원** (`usePrgrsSttsRows`, `useSaveElaprPrgrsStts`) | 없음 | +| 결재자 변경 | **AprvrPickerDialog** | 없음 | +| 전결 체크박스 | **있음** (`finalApprovalYn`) | 없음 | +| 반려 사유 | API에 `rjctRsn` 전달 | UI는 있으나 **payload에 미전달** | +| 저장 전 검증 | `hasUnsavedPrgrsSttsChanges` | 없음 | + +### 6-3. ElaprTmpltPickerDialog + +| 항목 | fnrs | instl | +| --------------- | -------------------------------------------------------------------- | ----------------------- | +| UI | 2-pane: 서식 테이블 + **결재선 편집 그리드** | 단일 테이블 + 선택 버튼 | +| docSeComCd 필터 | `FNRS` | `INSTL` | +| onConfirm | `{ tmplt, aplnRows }` 편집 결과 | `onSelect(tmplt)`만 | +| 검증 | `validateAllRowsForSave`, `validateAtrzOrder`, `validateUniqueSeqNo` | 없음 | + +--- + +## 7. Entity / API / Hook + +### 7-1. Service 메서드 + +| 메서드 | fnrs | instl | +| ----------------------- | --------------------------- | --------- | +| 목록 | `findPage` | `getPage` | +| 상세 | `findById` | `getById` | +| saveApply | O | O | +| techReview | O | O | +| changePrgrsSttsComCd | O | O | +| remove | O | O | +| **submitSupplement** | **O** (`/submitsupplement`) | **X** | +| **submitReqSupplement** | **O** (`/reqsupplement`) | **X** | + +### 7-2. 상세 DTO 확장 + +**fnrs 추가 필드** + +- `fnrsSharList`, `sharAtchFileList` +- `splmntDmndList` (typed) + +**instl 추가 필드** + +- `sncnSeComCd` / `sncnSeCdNm` (인가구분) +- `bgtBizCd` (예산사업코드) +- `rtlztnFcltSeComNm`, `rtlztnUsgSeComNm` +- `splmntDmndList` 타입은 base에 존재하나 view/API 미연결 +- `instlSncnRegionService` (시도/시군구 — 목록은 adstdstr 사용, 미활용) + +### 7-3. save-payload-utils + +| 함수 | fnrs | instl | +| -------------------------------- | ---------------- | --------- | +| buildSavePayload | + `fnrsSharList` | shar 없음 | +| buildTechReviewReplyPayload | O | O | +| **buildSupplementReqPayload** | **O** | **X** | +| **buildSupplementSubmitPayload** | **O** | **X** | + +--- + +## 8. 비즈니스 도메인 차이 + +| 영역 | fnrs-cnsltn | instl-sncn | +| -------------- | ----------------------------------------- | ------------------------ | +| 업무 코드 | `FNRS` | `INSTL` | +| TaskSe | FNRS 하위 | INSTL 하위 | +| 고시 버튼 라벨 | "재원협의 고시" | "설치인가 고시" | +| 재원분담 | **핵심 도메인** (`fnrs-shar-se`) | 해당 없음 | +| 인가구분 | 없음 | **`SNCNSE` 코드 (필수)** | +| 예산 식별 | `cfmtnBgtBizNo` | `bgtBizCd` | +| 진행상태 코드 | SV, ST, LE, ER, TE, EP, TA, TB, SL, TP 등 | **동일 세트 공유** | +| 역할 매핑 | ROLE_USER05/04/03/01 | 동일 | + +--- + +## 9. fnrs-cnsltn에만 있는 기능 (추가 요구사항 후보) + +instl-sncn 정렬 시 **우선 이식 대상**: + +1. 목록 행 클릭 조건부 edit 라우팅 (`SV`/`SL` + 등록자) +2. 보완요청 전체 플로우 — view 섹션, payload builder, API, FAB handler +3. 보완완료 API 연동 (`submitSupplement`) +4. 등록/수정 폼 완성 — 신청완료, 삭제, 저장 후 edit 유지 +5. 결재선 DnD 편집형 `ElaprApprovalDialog` (+ aprvr-picker, sortable rows) +6. 결재선 편집형 `ElaprTmpltPickerDialog` (+ apln grid, validation) +7. 검토완료 → 결재 다이얼로그 연계 흐름 +8. 검색 필터 "전체" 옵션 및 `admdstCd` 통합 방식 (백엔드 API에 맞게) +9. Edit 페이지 로딩 Skeleton +10. entity에 supplement API/hooks 추가 + +--- + +## 10. instl-sncn에만 있는 기능 (유지 필요) + +1. **`sncnSeComCd` (사업구분/인가구분)** — 도메인 고유 +2. **`bgtBizCd` / `bgtAplyYr`** BnsPicker 연동 +3. **`history-info-section.tsx`** — 구현만 있고 view 미연결 (활성화 또는 삭제 결정 필요) +4. entity **`instlSncnRegionService`** — 목록은 adstdstr 사용 중이라 중복/레거시 가능 +5. **`INSTL_SNCN_REGISTER_SELECTION_KEY`** sessionStorage 키 + +--- + +## 11. 동일·거의 동일 패턴 + +| 영역 | 동일도 | +| ----------------------------------------------------- | --------------------- | +| App Router 4-route 구조 | 100% | +| `button-config.ts` 역할×상태 매트릭스 | ~99% (액션명만 다름) | +| `columns.tsx` 진행상황 Badge | ~100% | +| checklist / contact / scale / cost / attachments 섹션 | ~95% (import 경로만) | +| review-step-utils 로직 | ~100% (상수명만 다름) | +| TaskSePickerDialog 신청 플로우 | 100% | +| FAB 전체 구조·대부분 handler | ~85% | +| checklist-form-state, business-cost-utils | 거의 동일 | +| shared widgets (`@/widgets/pbadms-sprt`) | 동일 | + +--- + +## 12. instl-sncn 정렬 권장 우선순위 + +| 우선순위 | 항목 | 근거 | +| -------- | ------------------------------------------------------- | ---------------------------- | +| P0 | 보완요청/보완완료 API + UI | fnrs 완전 구현 vs instl TODO | +| P0 | ElaprApprovalDialog 고도화 | 결재선 편집·전결·반려사유 | +| P0 | ElaprTmpltPickerDialog 2-pane | 검토자지정 품질 | +| P1 | Form 신청완료/삭제 + 저장 후 edit 유지 | 등록 UX parity | +| P1 | 목록 행 클릭 edit 라우팅 | fnrs 추가 요구사항 | +| P1 | 검토완료 → 결재 다이얼로그 연계 | 흐름 통일 | +| P2 | sort fallback 불일치 수정 (`regDt` vs `instlSncnDocNo`) | instl 내부 버그성 | +| P2 | history-info-section 연결 또는 삭제 | dead code 정리 | +| P3 | URL search params 동기화 | 양쪽 모두 미구현 (공통 개선) | + +--- + +## 13. 참고 코드 위치 + +| 주제 | 경로 | +| ---------------------------- | ------------------------------------------------------------ | +| 목록 edit 라우팅 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-list.tsx` | +| 목록 항상 view (instl) | `pages/instl-sncn/ui/instl-list.tsx` | +| view 추가 섹션 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` | +| FAB 보완 TODO (instl) | `pages/instl-sncn/ui/components/floating-action-buttons.tsx` | +| 결재 다이얼로그 (fnrs) | `pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx` | +| 결재 다이얼로그 (instl) | `pages/instl-sncn/ui/components/elapr-approval-dialog.tsx` | +| 폼 완성도 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-form-page.tsx` | +| 폼 단순화 (instl) | `pages/instl-sncn/ui/instl-form-page.tsx` | +| supplement API (fnrs entity) | `entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts` | diff --git a/apps/as/package.json b/apps/as/package.json index e7fa14d85..71addd9fe 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -20,6 +20,7 @@ "@faker-js/faker": "^9.9.0", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-tooltip": "^1.2.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-zone": "^2.8.0", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", diff --git a/apps/as/public/favicon.ico b/apps/as/public/favicon.ico new file mode 100644 index 000000000..d304b6981 Binary files /dev/null and b/apps/as/public/favicon.ico differ diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts index 25991ab97..f1931d556 100644 --- a/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts +++ b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts @@ -26,8 +26,10 @@ async function findById(elaprPrgrsId: string): Promise { - const response = await client.post(BASE, body); +async function save( + requestDtos: ElaprPrgrsSttsSaveRequestDto[], +): Promise { + const response = await client.post(BASE, requestDtos); return response.data; } diff --git a/apps/as/src/entities/elapr/api/queries.ts b/apps/as/src/entities/elapr/api/queries.ts index 8100a4d3f..e93c1540e 100644 --- a/apps/as/src/entities/elapr/api/queries.ts +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -272,7 +272,8 @@ export function useSaveElaprPrgrsStts() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: ElaprPrgrsSttsSaveRequestDto) => elaprPrgrsSttsService.save(body), + mutationFn: (requestDtos: ElaprPrgrsSttsSaveRequestDto[]) => + elaprPrgrsSttsService.save(requestDtos), onSuccess: () => { invalidateElaprPrgrsSttsQueries(queryClient); }, diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index 42673c996..577742c77 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -43,16 +43,27 @@ export { validateAtrzOrder, validateUniqueSeqNo, } from "./lib/apln-validators"; -export { - buildElaprPrgrsSttsListFromApln, - buildElaprPrgrsSttsListFromAplnRows, -} from "./lib/dmnd-save-utils"; export { findCurrentUserPendingApprovalRequest, hasCurrentUserPendingApprovalRequest, } from "./lib/approval-request-utils"; +export { + buildElaprPrgrsSttsListFromApln, + buildElaprPrgrsSttsListFromAplnRows, +} from "./lib/dmnd-save-utils"; +export type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils"; +export { + assignPrgrsSttsAtrzSeqNumbers, + buildPrgrsSttsSavePayload, + hasUnsavedPrgrsSttsChanges, + isPrgrsSttsRowEditable, + PRGRS_STTS_WAITING_CD, + validateAllPrgrsSttsRows, + validatePrgrsSttsRow, +} from "./lib/prgrs-stts-save-utils"; export { createDraftRowKey, useAplnRows } from "./lib/use-apln-rows"; export { useDndSensors } from "./lib/use-dnd-sensors"; +export { usePrgrsSttsRows } from "./lib/use-prgrs-stts-rows"; export { ELAPR_APLN_SAVE_MODE, type ElaprApln, @@ -77,11 +88,13 @@ export type { ElaprApproverInsertRequestDto, ElaprPrgrsSttsMyDocSearchParams, ElaprPrgrsSttsResponseDto, + ElaprPrgrsSttsSaveMode, ElaprPrgrsSttsSaveRequestDto, ElaprPrgrsSttsSearchDto, ElaprPrgrsSttsService, ElaprProcessRequestDto, } from "./model/elapr-prgrs-stts-interface"; +export { ELAPR_PRGRS_STTS_SAVE_MODE } from "./model/elapr-prgrs-stts-interface"; export type { ElaprTmplt, ElaprTmpltSaveRequest, diff --git a/apps/as/src/entities/elapr/lib/apln-validators.ts b/apps/as/src/entities/elapr/lib/apln-validators.ts index adfe167db..3ab1bcd30 100644 --- a/apps/as/src/entities/elapr/lib/apln-validators.ts +++ b/apps/as/src/entities/elapr/lib/apln-validators.ts @@ -3,13 +3,18 @@ import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; const ATRZ_RECEIVER_CD = "ATRZ01"; const ATRZ_FINAL_CD = "ATRZ99"; +interface AtrzOrderValidateRow { + atrzSeComCd?: string; + isDeleted?: boolean; +} + export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] { return rows.filter((row) => !row.isDeleted); } /** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */ -export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null { - const visibleRows = getVisibleRows(rows); +export function validateAtrzOrder(rows: AtrzOrderValidateRow[]): string | null { + const visibleRows = rows.filter((row) => !row.isDeleted); const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length; if (receiverCount === 0) { diff --git a/apps/as/src/entities/elapr/lib/approval-request-utils.ts b/apps/as/src/entities/elapr/lib/approval-request-utils.ts index db9cae9dc..7886e0ad2 100644 --- a/apps/as/src/entities/elapr/lib/approval-request-utils.ts +++ b/apps/as/src/entities/elapr/lib/approval-request-utils.ts @@ -7,19 +7,17 @@ export function hasCurrentUserPendingApprovalRequest( if (!currentUserUuid) return false; return list.some( - (row) => - row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + (row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", ); } export function findCurrentUserPendingApprovalRequest( - list: ElaprDocPrgrsResponseDto[], - currentUserUuid: string | undefined, + list?: ElaprDocPrgrsResponseDto[], + currentUserUuid?: string | undefined, ): ElaprDocPrgrsResponseDto | undefined { if (!currentUserUuid) return undefined; - return list.find( - (row) => - row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + return list?.find( + (row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", ); } diff --git a/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts b/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts new file mode 100644 index 000000000..db6ee3e5c --- /dev/null +++ b/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts @@ -0,0 +1,172 @@ +import { + ELAPR_PRGRS_STTS_SAVE_MODE, + type ElaprPrgrsSttsSaveRequestDto, +} from "../model/elapr-prgrs-stts-interface"; + +export const PRGRS_STTS_WAITING_CD = "ATRZ_PRGRS_001"; + +/** 서버 행 비교용 최소 필드 */ +export interface PrgrsSttsServerRow { + elaprPrgrsId?: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; +} + +/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */ +export interface ElaprPrgrsSttsRow { + rowKey: string; + elaprPrgrsId?: string; + elaprDmndId?: string; + aprvrUuid?: string; + aprvrNm?: string; + atrzSeComCd?: string; + atrzSeComNm?: string; + atrzSeqNo?: number; + atrzPrgrsSttsComCd?: string; + atrzPrgrsSttsComNm?: string; + rjctRsn?: string | null; + ognzNm?: string; + jbpsSeNm?: string; + jbgdSeNm?: string; + isNew?: boolean; + isDirty?: boolean; + /** 소프트 삭제 — 최종 저장 시 mode:D */ + isDeleted?: boolean; + /** 수정 취소용 스냅샷 */ + snapshot?: ElaprPrgrsSttsRow; +} + +export function isPrgrsSttsRowEditable(row: ElaprPrgrsSttsRow): boolean { + return row.isNew === true || row.atrzPrgrsSttsComCd === PRGRS_STTS_WAITING_CD; +} + +function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] { + return rows.filter((row) => !row.isDeleted); +} + +/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */ +export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] { + return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); +} + +export function validatePrgrsSttsRow(row: ElaprPrgrsSttsRow): string | null { + if (!row.atrzSeComCd?.trim()) { + return "결재구분을 선택해 주세요."; + } + if (!row.aprvrUuid?.trim()) { + return "결재자를 선택해 주세요."; + } + return null; +} + +export function validateAllPrgrsSttsRows( + rows: ElaprPrgrsSttsRow[], + isEditingLocked: boolean, +): string | null { + if (isEditingLocked) { + return "편집 중인 행을 먼저 저장하거나 취소해 주세요."; + } + + const visibleRows = getVisibleRows(rows); + if (visibleRows.length === 0) { + return "저장할 결재선이 없습니다."; + } + + for (const row of visibleRows) { + const fieldError = validatePrgrsSttsRow(row); + if (fieldError) return fieldError; + } + + return null; +} + +function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServerRow): boolean { + return ( + row.aprvrUuid !== serverRow.aprvrUuid || + row.atrzSeComCd !== serverRow.atrzSeComCd || + row.atrzSeqNo !== serverRow.atrzSeqNo + ); +} + +function shouldIncludeUpdate(row: ElaprPrgrsSttsRow, serverRows: PrgrsSttsServerRow[]): boolean { + if (row.isDirty) return true; + if (!row.elaprPrgrsId) return false; + + const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId); + if (!serverRow) return false; + + return rowDiffersFromServer(row, serverRow); +} + +export function hasUnsavedPrgrsSttsChanges( + rows: ElaprPrgrsSttsRow[], + serverRows: PrgrsSttsServerRow[], +): boolean { + if (rows.some((row) => row.isDeleted && row.elaprPrgrsId)) return true; + if (rows.some((row) => row.isNew && !row.isDeleted)) return true; + + const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) continue; + if (!row.elaprPrgrsId) continue; + if (row.isDirty) return true; + + const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId); + if (!serverRow) continue; + if (rowDiffersFromServer(row, serverRow)) return true; + } + + return false; +} + +export function buildPrgrsSttsSavePayload( + rows: ElaprPrgrsSttsRow[], + elaprDmndId: string, + serverRows: PrgrsSttsServerRow[], +): ElaprPrgrsSttsSaveRequestDto[] { + const payload: ElaprPrgrsSttsSaveRequestDto[] = []; + + for (const row of rows) { + if (row.isDeleted && row.elaprPrgrsId) { + payload.push({ + mode: ELAPR_PRGRS_STTS_SAVE_MODE.DELETE, + elaprPrgrsId: row.elaprPrgrsId, + elaprDmndId, + }); + } + } + + const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) { + payload.push({ + mode: ELAPR_PRGRS_STTS_SAVE_MODE.CREATE, + elaprDmndId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd, + }); + continue; + } + + if (!row.elaprPrgrsId) continue; + + if (shouldIncludeUpdate(row, serverRows)) { + payload.push({ + mode: ELAPR_PRGRS_STTS_SAVE_MODE.UPDATE, + elaprPrgrsId: row.elaprPrgrsId, + elaprDmndId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd, + }); + } + } + + return payload; +} diff --git a/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts b/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts new file mode 100644 index 000000000..297e574a4 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts @@ -0,0 +1,210 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { arrayMove } from "@dnd-kit/sortable"; + +import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model"; + +import { + assignPrgrsSttsAtrzSeqNumbers, + type ElaprPrgrsSttsRow, + isPrgrsSttsRowEditable, + PRGRS_STTS_WAITING_CD, +} from "./prgrs-stts-save-utils"; +import { createDraftRowKey } from "./use-apln-rows"; + +function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] { + return rows.filter((row) => !row.isDeleted); +} + +function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow { + return { + rowKey: item.elaprPrgrsId ?? createDraftRowKey(), + elaprPrgrsId: item.elaprPrgrsId, + elaprDmndId: item.elaprDmndId, + aprvrUuid: item.aprvrUuid, + aprvrNm: item.aprvrUuidNm, + atrzSeComCd: item.atrzSeComCd, + atrzSeComNm: item.atrzSeComNm, + atrzSeqNo: item.atrzSeqNo, + atrzPrgrsSttsComCd: item.atrzPrgrsSttsComCd, + atrzPrgrsSttsComNm: item.atrzPrgrsSttsComNm, + rjctRsn: item.rjctRsn, + ognzNm: item.ognzNm, + jbpsSeNm: item.jbpsSeNm, + jbgdSeNm: item.jbgdSeNm, + isNew: false, + isDirty: false, + isDeleted: false, + }; +} + +interface UsePrgrsSttsRowsOptions { + serverRows?: ElaprDocPrgrsClientResponseDto[]; + elaprDmndId?: string | null; +} + +export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOptions) { + const [rows, setRows] = useState([]); + const [editingRowKey, setEditingRowKey] = useState(null); + + useEffect(() => { + setRows((serverRows ?? []).map(toRow)); + setEditingRowKey(null); + }, [serverRows]); + + const isEditingLocked = editingRowKey != null; + const visibleRows = getVisibleRows(rows); + + const addRow = useCallback(() => { + if (!elaprDmndId || isEditingLocked) return; + + const rowKey = createDraftRowKey(); + + setRows((prev) => [ + ...prev, + { + rowKey, + elaprDmndId, + atrzSeComCd: "", + aprvrUuid: "", + aprvrNm: "", + atrzPrgrsSttsComCd: PRGRS_STTS_WAITING_CD, + isNew: true, + isDirty: true, + isDeleted: false, + }, + ]); + setEditingRowKey(rowKey); + }, [elaprDmndId, isEditingLocked]); + + const startEdit = useCallback( + (rowKey: string) => { + if (isEditingLocked) return; + + const target = rows.find((r) => r.rowKey === rowKey); + if (!target || !isPrgrsSttsRowEditable(target)) return; + + setEditingRowKey(rowKey); + setRows((prev) => + prev.map((row) => (row.rowKey === rowKey ? { ...row, snapshot: { ...row } } : row)), + ); + }, + [isEditingLocked, rows], + ); + + const updateRow = useCallback( + (rowKey: string, patch: Partial) => { + if (editingRowKey !== rowKey) return; + setRows((prev) => + prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)), + ); + }, + [editingRowKey], + ); + + const cancelEdit = useCallback(() => { + if (!editingRowKey) return; + + setRows((prev) => { + const target = prev.find((r) => r.rowKey === editingRowKey); + if (target?.isNew) { + return prev.filter((r) => r.rowKey !== editingRowKey); + } + return prev.map((row) => { + if (row.rowKey !== editingRowKey) return row; + if (row.snapshot) { + const { + snapshot: _s, + isNew: _n, + isDirty: _d, + isDeleted: _del, + ...restored + } = row.snapshot; + return { ...restored, isNew: false, isDirty: false, isDeleted: false }; + } + return { ...row, isDirty: false }; + }); + }); + setEditingRowKey(null); + }, [editingRowKey]); + + const commitEdit = useCallback((rowKey: string) => { + setRows((prev) => + prev.map((row) => { + if (row.rowKey !== rowKey) return row; + const { snapshot: _s, ...committed } = row; + return { ...committed, isDirty: true }; + }), + ); + setEditingRowKey(null); + }, []); + + const markDeleted = useCallback((rowKey: string) => { + setRows((prev) => { + const target = prev.find((r) => r.rowKey === rowKey); + if (!target || !isPrgrsSttsRowEditable(target)) return prev; + + if (target.isNew) { + return prev.filter((r) => r.rowKey !== rowKey); + } + + return prev.map((row) => + row.rowKey === rowKey ? { ...row, isDeleted: true, isDirty: true } : row, + ); + }); + setEditingRowKey((key) => (key === rowKey ? null : key)); + }, []); + + const reorderRows = useCallback( + (activeKey: string, overKey: string): ElaprPrgrsSttsRow[] => { + if (isEditingLocked) return []; + + let payloadRows: ElaprPrgrsSttsRow[] = []; + + setRows((prev) => { + const visible = getVisibleRows(prev); + const activeRow = visible.find((r) => r.rowKey === activeKey); + const overRow = visible.find((r) => r.rowKey === overKey); + if (!activeRow || !overRow) return prev; + if (!isPrgrsSttsRowEditable(activeRow) || !isPrgrsSttsRowEditable(overRow)) return prev; + + const oldIndex = visible.findIndex((r) => r.rowKey === activeKey); + const newIndex = visible.findIndex((r) => r.rowKey === overKey); + if (oldIndex < 0 || newIndex < 0) return prev; + + const reorderedVisible = assignPrgrsSttsAtrzSeqNumbers( + arrayMove(visible, oldIndex, newIndex), + ).map((row) => (isPrgrsSttsRowEditable(row) ? { ...row, isDirty: true } : row)); + + const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey)); + const deletedRows = prev.filter((r) => r.isDeleted); + const merged = [ + ...reorderedVisible, + ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)), + ]; + + payloadRows = reorderedVisible.filter(isPrgrsSttsRowEditable); + return merged; + }); + + return payloadRows; + }, + [isEditingLocked], + ); + + return { + rows, + visibleRows, + editingRowKey, + isEditingLocked, + addRow, + startEdit, + updateRow, + cancelEdit, + commitEdit, + markDeleted, + reorderRows, + }; +} diff --git a/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts index 2ab1c5e14..46dad56ec 100644 --- a/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts @@ -1,5 +1,15 @@ import type { PageableParams, PageResponse } from "@/shared/model"; +export const ELAPR_PRGRS_STTS_SAVE_MODE = { + CREATE: "C", + READ: "R", + UPDATE: "U", + DELETE: "D", +} as const; + +export type ElaprPrgrsSttsSaveMode = + (typeof ELAPR_PRGRS_STTS_SAVE_MODE)[keyof typeof ELAPR_PRGRS_STTS_SAVE_MODE]; + /** 백엔드 ElaprPrgrsSttsResponseDto */ export interface ElaprPrgrsSttsResponseDto { elaprPrgrsId: string; @@ -16,10 +26,12 @@ export interface ElaprPrgrsSttsResponseDto { /** 백엔드 ElaprPrgrsSttsSaveRequestDto */ export interface ElaprPrgrsSttsSaveRequestDto { + mode?: ElaprPrgrsSttsSaveMode; + elaprPrgrsId?: string; elaprDmndId?: string; - aprvrUuid: string; - atrzSeComCd: string; - atrzSeqNo: number; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; atrzPrgrsSttsComCd?: string; } @@ -56,7 +68,7 @@ export interface ElaprApproverInsertRequestDto { export interface ElaprPrgrsSttsService { findAllByDmndId(elaprDmndId?: string): Promise; findById(elaprPrgrsId: string): Promise; - save(body: ElaprPrgrsSttsSaveRequestDto): Promise; + save(requestDtos: ElaprPrgrsSttsSaveRequestDto[]): Promise; process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise; processByDocNoAndAprvrUuid( elaprDocNo: string, diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts index c7ef294b9..e6f95db5b 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts @@ -1,12 +1,12 @@ -import type { CklstRvwRsltTreeDto, CklstResultItem } from "@/entities/fnrs-cnsltn"; +import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn"; import { type ChecklistFormState, type ChecklistProgress, type ChecklistRowKey, type ChecklistStepMap, - type ReviewStepCode, REVIEW_STEP_CODES, + type ReviewStepCode, } from "../model/view-types"; export function buildChecklistRowKey( @@ -89,7 +89,7 @@ export function computeReviewStepChecklistProgress( const val = results[key]; return acc + (val === "Y" || val === "N" ? 1 : 0); }, 0); - const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + const percent = total > 0 ? Math.round((completed / total) * 100) : 100; return { total, completed, percent }; } diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts index d1d2e2352..f4c11c40a 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts @@ -1,5 +1,6 @@ import type { FnrsCnsltnMngDetResponseDto, + FnrsCnsltnRvwOpnnResponseDto, FnrsCnsltnRvwOpnnSaveRequestDto, } from "@/entities/fnrs-cnsltn"; @@ -11,35 +12,83 @@ export interface ReviewOpinionFormData { rvwOpnnSn?: number; } +export interface ReviewOpinionGetFormDataResult { + formData: ReviewOpinionFormData; + rvwList: FnrsCnsltnRvwOpnnResponseDto[]; +} + +function hasReviewOpinionContent(formData: ReviewOpinionFormData) { + return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); +} + +function mapServerItemToSaveDto( + item: FnrsCnsltnRvwOpnnResponseDto, + fnrsCnsltnDocNo: string, +): FnrsCnsltnRvwOpnnSaveRequestDto | null { + if (item.rvwOpnnSn == null) return null; + + return { + fnrsCnsltnDocNo, + rvwOpnnSn: item.rvwOpnnSn, + opnrSeComCd: item.opnrSeComCd, + opnrNm: item.opnrNm, + rvwRsltCn: item.rvwRsltCn, + rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined, + atchFileGroupId: item.atchFileGroupId || undefined, + }; +} + +function mapFormDataToSaveDto( + formData: ReviewOpinionFormData, + fnrsCnsltnDocNo: string, + opnrNm?: string, +): FnrsCnsltnRvwOpnnSaveRequestDto { + const base = { + fnrsCnsltnDocNo, + opnrSeComCd: formData.opnrSeComCd, + opnrNm: opnrNm ?? "", + rvwRsltCn: formData.rvwRsltCn, + rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined, + atchFileGroupId: formData.atchFileGroupId || undefined, + }; + + if (formData.rvwOpnnSn != null) { + return { ...base, rvwOpnnSn: formData.rvwOpnnSn }; + } + + return base as FnrsCnsltnRvwOpnnSaveRequestDto; +} + export function toRvwOpnnSaveDto( data: FnrsCnsltnMngDetResponseDto, - reviewOpinionFormData: ReviewOpinionFormData | null | undefined, + result: ReviewOpinionGetFormDataResult | null | undefined, opnrNm?: string, ): FnrsCnsltnRvwOpnnSaveRequestDto[] | undefined { - if (!reviewOpinionFormData || !data.detDto) return undefined; + if (!result || !data.detDto) return undefined; - const hasContent = - reviewOpinionFormData.rvwRsltCn.trim() || - reviewOpinionFormData.rvwRsltAnmlyYn || - reviewOpinionFormData.atchFileGroupId; + const { formData, rvwList } = result; + const fnrsCnsltnDocNo = data.detDto.fnrsCnsltnDocNo; - if (!hasContent) return undefined; + const list: FnrsCnsltnRvwOpnnSaveRequestDto[] = rvwList + .map((item) => mapServerItemToSaveDto(item, fnrsCnsltnDocNo)) + .filter((item): item is FnrsCnsltnRvwOpnnSaveRequestDto => item != null); - const existingSn = - reviewOpinionFormData.rvwOpnnSn ?? - data.rvwList?.find((item) => item.opnrSeComCd === reviewOpinionFormData.opnrSeComCd) - ?.rvwOpnnSn ?? - 1; + if (!hasReviewOpinionContent(formData)) { + return list.length > 0 ? list : undefined; + } - return [ - { - fnrsCnsltnDocNo: data.detDto.fnrsCnsltnDocNo, - rvwOpnnSn: existingSn, - opnrSeComCd: reviewOpinionFormData.opnrSeComCd, - opnrNm: opnrNm ?? "", - rvwRsltCn: reviewOpinionFormData.rvwRsltCn, - rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined, - atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined, - }, - ]; + const formDto = mapFormDataToSaveDto(formData, fnrsCnsltnDocNo, opnrNm); + + if (formData.rvwOpnnSn != null) { + const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn); + if (existingIndex >= 0) { + list[existingIndex] = formDto; + } else { + list.push(formDto); + } + } else { + list.push(formDto); + } + + return list.length > 0 ? list : undefined; } diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts index cd561c762..3c6b5e3a6 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts @@ -16,7 +16,7 @@ import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-s import type { ChecklistFormState, ReviewStepCode } from "../model/view-types"; import { toCklstSaveDto } from "./checklist-utils"; -import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils"; +import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils"; export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] { return bizSclList @@ -88,7 +88,7 @@ export function buildTechReviewReplyPayload( reviewStep: ReviewStepCode, prgrsSttsComCd: string, techReViewSaveCd: string, - reviewOpinionFormData?: ReviewOpinionFormData | null, + reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null, opnrNm?: string, ): FnrsCnsltnApplySaveRequestDto { return { diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx index da4480f6c..a6737ec3e 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx @@ -2,31 +2,49 @@ import { useState } from "react"; +import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { ENDPOINT } from "@/shared/config"; import { useAlertStore } from "@/shared/lib"; -import { elaprPrgrsSttsService, findCurrentUserPendingApprovalRequest, useElaprDocPrgrsByDocNo } from "@/entities/elapr"; +import { + buildPrgrsSttsSavePayload, + elaprPrgrsSttsService, + findCurrentUserPendingApprovalRequest, + hasUnsavedPrgrsSttsChanges, + isPrgrsSttsRowEditable, + useDndSensors, + useElaprDocPrgrsByDocNo, + usePrgrsSttsRows, + useSaveElaprPrgrsStts, + validateAllPrgrsSttsRows, + validateAtrzOrder, + validatePrgrsSttsRow, +} from "@/entities/elapr"; import { Button, - cn, + Checkbox, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, + Label, Skeleton, Table, TableBody, - TableCell, TableHead, TableHeader, TableRow, Textarea, } from "@common/components"; -import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils"; +import type { ReviewerOption } from "../../model/reviewer-option"; + +import { AprvrPickerDialog } from "./aprvr-picker-dialog"; +import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row"; interface ElaprApprovalDialogProps { open: boolean; @@ -36,6 +54,8 @@ interface ElaprApprovalDialogProps { onSuccess: () => void; } +const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요."; + function ElaprApprovalDialog({ open, onOpenChange, @@ -47,12 +67,33 @@ function ElaprApprovalDialog({ const alert = useAlertStore((state) => state.setMessage); const [rjctRsn, setRjctRsn] = useState(""); const [isProcessing, setIsProcessing] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + const [pickingRowKey, setPickingRowKey] = useState(null); + const [finalApprovalYn, setFinalApprovalYn] = useState(false); - const { data: elaprDocPrgrsList = [], isLoading } = useElaprDocPrgrsByDocNo( - open ? elaprDocNo : null, - ); + const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null); - const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList); + const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId; + + const { + rows, + visibleRows, + editingRowKey, + isEditingLocked, + addRow, + startEdit, + updateRow, + cancelEdit, + commitEdit, + markDeleted, + reorderRows, + } = usePrgrsSttsRows({ + serverRows: elaprDocPrgrsList, + elaprDmndId, + }); + + const savePrgrsSttsMutation = useSaveElaprPrgrsStts(); + const sensors = useDndSensors(); const currentUserPrgrs = findCurrentUserPendingApprovalRequest( elaprDocPrgrsList, @@ -60,13 +101,106 @@ function ElaprApprovalDialog({ ); const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId); + const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined; + const isBusy = isProcessing || savePrgrsSttsMutation.isPending; + const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + reorderRows(String(active.id), String(over.id)); + } + + function handleRowSave(rowKey: string) { + const row = rows.find((r) => r.rowKey === rowKey); + if (!row) return; + + const fieldError = validatePrgrsSttsRow(row); + if (fieldError) { + alert(fieldError); + return; + } + + commitEdit(rowKey); + } + + function handleReviewerSelect(reviewer: ReviewerOption) { + if (!pickingRowKey) return; + updateRow(pickingRowKey, { + aprvrUuid: reviewer.uuid, + aprvrNm: reviewer.name, + ognzNm: reviewer.deptNm, + }); + setPickingRowKey(null); + } + + function assertPrgrsSttsSaved(): boolean { + if (isEditingLocked) { + alert(UNSAVED_PRGRS_STTS_MESSAGE); + return false; + } + + if (isPrgrsSttsUnsaved) { + alert(UNSAVED_PRGRS_STTS_MESSAGE); + return false; + } + + return true; + } + + async function handleSavePrgrsStts() { + if (!elaprDmndId) { + alert("결재 요청 정보가 없습니다."); + return; + } + + const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked); + if (validationError) { + alert(validationError); + return; + } + + const orderError = validateAtrzOrder(rows); + if (orderError) { + alert(orderError); + return; + } + + const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []); + if (payload.length === 0) { + alert("변경된 결재선이 없습니다."); + return; + } + + setIsProcessing(true); + try { + await savePrgrsSttsMutation.mutateAsync(payload); + alert("결재선이 저장되었습니다."); + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo], + }); + onSuccess(); + } catch (error: unknown) { + let errorMessage = "결재선 저장 중 오류가 발생했습니다."; + if (error instanceof AxiosError) { + errorMessage = error.response?.data?.message ?? errorMessage; + } + alert(errorMessage); + } finally { + setIsProcessing(false); + } + } + async function handleApprove() { if (!currentUserPrgrs?.elaprPrgrsId) return; + if (!assertPrgrsSttsSaved()) return; setIsProcessing(true); try { await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, { atrzPrgrsSttsComCd: "ATRZ_PRGRS_003", + finalApprovalYn, }); alert("결재가 완료되었습니다."); queryClient.invalidateQueries({ @@ -93,11 +227,13 @@ function ElaprApprovalDialog({ alert("반려 사유를 입력해주세요."); return; } + if (!assertPrgrsSttsSaved()) return; setIsProcessing(true); try { await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, { atrzPrgrsSttsComCd: "ATRZ_PRGRS_004", + rjctRsn: rjctRsn.trim(), }); alert("반려 처리가 완료되었습니다."); queryClient.invalidateQueries({ @@ -120,6 +256,7 @@ function ElaprApprovalDialog({ function handleClose() { setRjctRsn(""); + setPickingRowKey(null); onOpenChange(false); } @@ -132,61 +269,97 @@ function ElaprApprovalDialog({
-

결재 진행 단계

+
+

결재 진행 단계

+
+ + {elaprDmndId && ( + + )} +
+
+ {isLoading ? (
- ) : orderedApprovers.length === 0 ? ( -
결재 정보가 없습니다.
+ ) : visibleRows.length === 0 ? ( +
+ {elaprDmndId + ? "등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요." + : "결재 정보가 없습니다."} +
) : ( -
- + +
+ + + + + + + + - {orderedApprovers.map((row) => ( - - {row.atrzSeComNm ?? row.atrzSeComCd} - - ))} + + 결재순서 + 결재구분 + 결재자 + 결재상태 + 관리 - - {orderedApprovers.map((row) => { - const status = row.atrzPrgrsSttsComCd ?? ""; - const st = getApproverStatus(status); - - return ( - -
- - {st.label} - - {row.aprvrUuidNm ?? "-"} -
-
- {row.ognzNm ?? ""} - {row.jbpsSeNm || row.jbgdSeNm - ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}` - : ""} -
-
- ); - })} -
+ r.rowKey)} + strategy={verticalListSortingStrategy} + > + {visibleRows.map((row, index) => ( + startEdit(row.rowKey)} + onSave={() => handleRowSave(row.rowKey)} + onCancel={cancelEdit} + onDelete={() => markDeleted(row.rowKey)} + onChange={(patch) => updateRow(row.rowKey, patch)} + onPickAprvr={() => { + setPickingRowKey(row.rowKey); + setPickerOpen(true); + }} + /> + ))} +
-
+ )}
@@ -205,21 +378,40 @@ function ElaprApprovalDialog({
- - {canApprove && ( - <> - - - - )} +
+ + {canApprove && ( +
+
+ setFinalApprovalYn(checked === true)} + /> + +
+ + +
+ )} +
+ + ); } diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx index 5a5c87a44..bf3d02823 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx @@ -152,7 +152,7 @@ function FloatingActionButtons({ request, data, onSuccess: () => { - alert(successMessage ?? "처리가 완료되었습니다."); + if (successMessage) alert(successMessage); queryClient.invalidateQueries({ queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], }); @@ -388,7 +388,7 @@ function FloatingActionButtons({ checklistFormState, "KECO", "TE", - "CP", + "SV", getReviewOpinionFormData(), opnrNm, ); @@ -396,9 +396,9 @@ function FloatingActionButtons({ await executeAction({ action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn, request: payload, - successMessage: "검토완료 처리되었습니다.", }); techReviewOpinionRef?.current?.resetForm(); + setActiveDialog("elaprApproval"); } function handleDmndSplmntClick() { @@ -531,8 +531,6 @@ function FloatingActionButtons({ elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo} currentUserUuid={userInfo?.userUnqId ?? ""} onSuccess={() => { - setActiveDialog(null); - alert("결재가 완료되었습니다."); queryClient.invalidateQueries({ queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], }); diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx index 4d25cb4eb..46af147ca 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx @@ -2,7 +2,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Equal } from "lucide-react"; +import { MoveVertical } from "lucide-react"; import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code"; import type { ElaprAplnRow } from "@/entities/elapr"; @@ -63,7 +63,7 @@ function SortableAplnPickerRow({ {...listeners} aria-label="순서 변경" > - + {row.isNew ? "-" : (row.aplnId ?? "-")} diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-prgrs-stts-row.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-prgrs-stts-row.tsx new file mode 100644 index 000000000..795c9d08d --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-prgrs-stts-row.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { MoveVertical } from "lucide-react"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code"; +import { type ElaprPrgrsSttsRow, isPrgrsSttsRowEditable } from "@/entities/elapr"; +import { Button, cn, TableCell, TableRow } from "@common/components"; + +import { getApproverStatus } from "../../lib/approval-utils"; + +interface SortablePrgrsSttsRowProps { + row: ElaprPrgrsSttsRow; + displaySeqNo: number; + isEditing: boolean; + isLocked: boolean; + dragDisabled: boolean; + onStartEdit: () => void; + onSave: () => void; + onCancel: () => void; + onDelete: () => void; + onChange: (patch: Partial) => void; + onPickAprvr: () => void; +} + +function SortablePrgrsSttsRow({ + row, + displaySeqNo, + isEditing, + isLocked, + dragDisabled, + onStartEdit, + onSave, + onCancel, + onDelete, + onChange, + onPickAprvr, +}: SortablePrgrsSttsRowProps) { + const { data: atrzSeCodes } = useCodeList("ATRZSE"); + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: row.rowKey, + disabled: dragDisabled, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + }; + + const isEditable = isPrgrsSttsRowEditable(row); + const rowDisabled = isLocked && !isEditing; + const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd); + const status = getApproverStatus(row.atrzPrgrsSttsComCd); + const isRejectedRow = row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_004"; + const rjctRsn = row.rjctRsn?.trim(); + + const statusBadge = ( + + {status.label} + + ); + + return ( + + + {!dragDisabled && ( + + )} + + {displaySeqNo} + + {isEditing ? ( + + onChange({ + atrzSeComCd: v, + atrzSeComNm: findCodeLabel(atrzSeCodes, v), + }) + } + placeholder="결재구분" + /> + ) : ( + atrzSeLabel || "-" + )} + + +
+
+ {row.aprvrNm || row.aprvrUuid || "-"} + {isEditing && ( + + )} +
+ {!isEditing && (row.ognzNm || row.jbpsSeNm || row.jbgdSeNm) && ( +
+ {row.ognzNm ?? ""} + {row.jbpsSeNm ? ` · ${row.jbpsSeNm}` : ""} +
+ )} +
+
+ + {isRejectedRow && rjctRsn ? ( + + {statusBadge} + + {rjctRsn} + + + ) : ( + statusBadge + )} + + +
+ {isEditing ? ( + <> + + + + ) : isEditable ? ( + <> + + + + ) : ( + - + )} +
+
+
+ ); +} + +export { SortablePrgrsSttsRow }; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx index 22b73ff50..f3fc0090a 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx @@ -1,6 +1,7 @@ "use client"; import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn"; +import { Skeleton } from "@common/components"; import { FnrsFormPage } from "./fnrs-form-page"; @@ -12,7 +13,22 @@ function FnrsEditPage({ id }: FnrsEditPageProps) { const { data, isLoading } = useFnrsCnsltnMng(id); if (isLoading) { - return
불러오는 중...
; + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); } if (!data?.detDto) { diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx index 43fec9115..ddcd2074f 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useRouter } from "next/navigation"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQueryClient } from "@tanstack/react-query"; @@ -24,6 +25,7 @@ import { ChecklistFormSection, ContactInfoFormSection, FnrsSharFormSection, + isFormChecklistComplete, PrivacyConsentSection, recalculateFnrsSharTotals, toFnrsSharSaveList, @@ -49,6 +51,7 @@ interface FnrsFormPageProps { function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) { const queryClient = useQueryClient(); const router = useRouter(); + const { confirm } = useConfirm(); const userInfo = useUserStore((state) => state.userInfo); const alert = useAlertStore((state) => state.setMessage); const [isSubmitting, setIsSubmitting] = useState(false); @@ -57,9 +60,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) const { data: masterSharTree = [] } = useFnrsSharSeMngTree(); const { data: acntgTree = [] } = useAcntgSeMngTree(); const sharTree = - isEditMode && initialData?.fnrsSharList?.length - ? initialData.fnrsSharList - : masterSharTree; + isEditMode && initialData?.fnrsSharList?.length ? initialData.fnrsSharList : masterSharTree; const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]); const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]); @@ -72,47 +73,54 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) : getCreateDefaultValues(userInfo, taskSeComCd), }); + async function submitForm( + data: FnrsCnsltnRegistFormData, + prgrsSttsComCd: "SV" | "ST", + ): Promise { + const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems); + const validationMessage = validateBizCstSharBalance( + data.bizCstList, + withTotals, + sharItems, + costItems, + ); + if (validationMessage) { + alert(validationMessage); + return; + } + + const { taskSeComCd: formTaskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng; + + const payload = { + fnrsCnsltnMng: { + ...fnrsCnsltnMngRest, + taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd), + fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined, + prgrsSttsComCd, + }, + bzScaleList: data.bzScaleList, + bizCstList: data.bizCstList, + fnrsSharList: toFnrsSharSaveList(withTotals), + cklstResultList: data.cklstResultList.map((item) => ({ + ...item, + selected: item.selected ?? undefined, + })), + esntlAtchFileList: data.esntlAtchFileList, + }; + + return fnrsCnsltnMngService.saveApply(payload); + } + async function onSave(data: FnrsCnsltnRegistFormData) { if (isSubmitting) return; try { setIsSubmitting(true); - - const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems); - const validationMessage = validateBizCstSharBalance( - data.bizCstList, - withTotals, - sharItems, - costItems, - ); - if (validationMessage) { - alert(validationMessage); - return; - } - - const { taskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng; - - const payload = { - fnrsCnsltnMng: { - ...fnrsCnsltnMngRest, - taskSeComCd: formatTaskSeComCdForApi(taskSeComCd), - fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined, - prgrsSttsComCd: "SV", - }, - bzScaleList: data.bzScaleList, - bizCstList: data.bizCstList, - fnrsSharList: toFnrsSharSaveList(withTotals), - cklstResultList: data.cklstResultList.map((item) => ({ - ...item, - selected: item.selected ?? undefined, - })), - esntlAtchFileList: data.esntlAtchFileList, - }; - - const fnrsCnsltnDocNo = await fnrsCnsltnMngService.saveApply(payload); + const fnrsCnsltnDocNo = await submitForm(data, "SV"); + if (!fnrsCnsltnDocNo) return; alert("저장되었습니다."); - router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`); + router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`); if (isEditMode && id) { queryClient.invalidateQueries({ queryKey: [...fnrsCnsltnMngBaseKey, "findById", id], @@ -126,6 +134,63 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) } } + async function onApply(data: FnrsCnsltnRegistFormData) { + if (isSubmitting) return; + + if (!isFormChecklistComplete(data.cklstResultList)) { + alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다."); + return; + } + + try { + setIsSubmitting(true); + const fnrsCnsltnDocNo = await submitForm(data, "ST"); + if (!fnrsCnsltnDocNo) return; + + alert("신청 완료되었습니다."); + router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`); + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + if (id) { + queryClient.invalidateQueries({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", id], + }); + } + } catch (error) { + console.error("신청 실패:", error); + alert("신청 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + + function handleApplyClick() { + form.handleSubmit(onApply)(); + } + + async function handleDelClick() { + if (isSubmitting) return; + + const docNo = initialData?.detDto?.fnrsCnsltnDocNo; + if (!docNo) { + alert("문서번호가 없습니다."); + return; + } + + if (!(await confirm("삭제하시겠습니까?"))) return; + + try { + setIsSubmitting(true); + await fnrsCnsltnMngService.remove(docNo); + alert("삭제되었습니다."); + router.push("/fnrs-cnsltn"); + } catch (error) { + console.error("삭제 실패:", error); + alert("삭제 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + return (
@@ -150,10 +215,8 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
+ {isEditMode && ( + <> + + + + )}
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx index 6871dd41c..0e52eba19 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx @@ -123,7 +123,7 @@ function FnrsListPage() { - +