From 0141ed56c2a21cc601a11a64b0a759ce3f1bbed4 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 9 Jul 2026 16:17:51 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20feat:=20=EC=9E=AC=EC=9B=90=ED=98=91?= =?UTF-8?q?=EC=9D=98=20=EC=A0=84=EC=9E=90=EA=B2=B0=EC=9E=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/package.json | 1 + apps/as/src/entities/elapr/index.ts | 5 + .../src/entities/elapr/lib/apln-validators.ts | 9 +- .../elapr/lib/approval-request-utils.ts | 12 +- .../elapr/lib/prgrs-stts-save-utils.ts | 67 +++- .../entities/elapr/lib/use-prgrs-stts-rows.ts | 210 ++++++++++++ .../pages/fnrs-cnsltn/lib/checklist-utils.ts | 6 +- .../fnrs-cnsltn/lib/review-opinion-utils.ts | 95 ++++-- .../fnrs-cnsltn/lib/save-payload-utils.ts | 4 +- .../ui/components/elapr-approval-dialog.tsx | 318 ++++++++++++++---- .../ui/components/floating-action-buttons.tsx | 8 +- .../components/sortable-apln-picker-row.tsx | 4 +- .../ui/components/sortable-prgrs-stts-row.tsx | 179 ++++++++++ .../pages/fnrs-cnsltn/ui/fnrs-edit-page.tsx | 18 +- .../pages/fnrs-cnsltn/ui/fnrs-form-page.tsx | 160 ++++++--- .../as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx | 21 +- .../sections/tech-review-opinion-section.tsx | 32 +- apps/as/src/shared/ui/tooltip.tsx | 58 ++++ apps/as/src/widgets/pbadms-sprt/index.ts | 1 + .../pbadms-sprt/lib/checklist-form-utils.ts | 5 + .../pbadms-sprt/lib/fnrs-shar-form-utils.ts | 2 +- pnpm-lock.yaml | 16 +- 22 files changed, 1048 insertions(+), 183 deletions(-) create mode 100644 apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts create mode 100644 apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-prgrs-stts-row.tsx create mode 100644 apps/as/src/shared/ui/tooltip.tsx diff --git a/apps/as/package.json b/apps/as/package.json index ea9e0545a..ed4016352 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", "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index 2b09c533d..577742c77 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -56,9 +56,14 @@ 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, 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 index 5b9a1a352..db6ee3e5c 100644 --- a/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts +++ b/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts @@ -1,18 +1,34 @@ import { ELAPR_PRGRS_STTS_SAVE_MODE, - type ElaprPrgrsSttsResponseDto, 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 */ @@ -21,6 +37,10 @@ export interface ElaprPrgrsSttsRow { 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); } @@ -30,10 +50,38 @@ export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprP return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); } -function rowDiffersFromServer( - row: ElaprPrgrsSttsRow, - serverRow: ElaprPrgrsSttsResponseDto, -): boolean { +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 || @@ -41,10 +89,7 @@ function rowDiffersFromServer( ); } -function shouldIncludeUpdate( - row: ElaprPrgrsSttsRow, - serverRows: ElaprPrgrsSttsResponseDto[], -): boolean { +function shouldIncludeUpdate(row: ElaprPrgrsSttsRow, serverRows: PrgrsSttsServerRow[]): boolean { if (row.isDirty) return true; if (!row.elaprPrgrsId) return false; @@ -56,7 +101,7 @@ function shouldIncludeUpdate( export function hasUnsavedPrgrsSttsChanges( rows: ElaprPrgrsSttsRow[], - serverRows: ElaprPrgrsSttsResponseDto[], + serverRows: PrgrsSttsServerRow[], ): boolean { if (rows.some((row) => row.isDeleted && row.elaprPrgrsId)) return true; if (rows.some((row) => row.isNew && !row.isDeleted)) return true; @@ -79,7 +124,7 @@ export function hasUnsavedPrgrsSttsChanges( export function buildPrgrsSttsSavePayload( rows: ElaprPrgrsSttsRow[], elaprDmndId: string, - serverRows: ElaprPrgrsSttsResponseDto[], + serverRows: PrgrsSttsServerRow[], ): ElaprPrgrsSttsSaveRequestDto[] { const payload: ElaprPrgrsSttsSaveRequestDto[] = []; 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/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.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx index 09f18d229..dd9c2292e 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx @@ -5,8 +5,12 @@ import { useRouter } from "next/navigation"; import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; -import { useDataTable } from "@/shared/lib"; -import { type FnrsCnsltnMngSearchDto, useFnrsCnsltnMngPage } from "@/entities/fnrs-cnsltn"; +import { useDataTable, useUserStore } from "@/shared/lib"; +import { + FnrsCnsltnMngResponseDto, + type FnrsCnsltnMngSearchDto, + useFnrsCnsltnMngPage, +} from "@/entities/fnrs-cnsltn"; import { TaskSePickerDialog } from "@/widgets/pbadms-sprt"; import { Button } from "@common/components"; @@ -20,6 +24,7 @@ interface FnrsListProps { } function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProps) { + const userInfo = useUserStore((state) => state.userInfo); const router = useRouter(); const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false); @@ -38,6 +43,16 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp const pageQuery = useFnrsCnsltnMngPage(queryParams); const columns = useMemo(() => createFnrsCnsltnColumns(), []); + 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}`, + ); + }; + const { tableComponent } = useDataTable({ query: pageQuery, tableOptions: { @@ -52,7 +67,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp }, dataTableOptions: { viewPagination: true, - onRowClick: (row) => router.push(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`), + onRowClick: handleRowClick, }, }); diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx index f93878fdd..dee5f6c8f 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx @@ -21,7 +21,10 @@ import { Textarea, } from "@common/components"; -import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils"; +import type { + ReviewOpinionFormData, + ReviewOpinionGetFormDataResult, +} from "../../lib/review-opinion-utils"; import type { ReviewStepCode } from "../../model/view-types"; import { REVIEW_STEP_LABELS } from "../../model/view-types"; @@ -33,7 +36,7 @@ interface TechReviewOpinionSectionProps { } export interface TechReviewOpinionSectionHandle { - getFormData: () => ReviewOpinionFormData | null; + getFormData: () => ReviewOpinionGetFormDataResult | null; resetForm: () => void; } @@ -101,8 +104,11 @@ const TechReviewOpinionSection = forwardRef< } return { - ...formData, - opnrSeComCd: editableReviewStep, + formData: { + ...formData, + opnrSeComCd: editableReviewStep, + }, + rvwList, }; }, resetForm: () => { @@ -113,7 +119,7 @@ const TechReviewOpinionSection = forwardRef< setUploadedFile(null); }, }), - [editableReviewStep, formData], + [editableReviewStep, formData, rvwList], ); const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); @@ -168,8 +174,8 @@ const TechReviewOpinionSection = forwardRef< const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; return ( -
-

검토의견 작성

+
+

검토의견 작성

@@ -268,14 +274,18 @@ const TechReviewOpinionSection = forwardRef< <> {hasEnvSection ? ( - {envRvwList.length > 0 ? : null} - {renderOpinionForm("ENV")} +
+ {envRvwList.length > 0 ? : null} + {renderOpinionForm("ENV")} +
) : null} {hasKecoSection ? ( - {kecoRvwList.length > 0 ? : null} - {renderOpinionForm("KECO")} +
+ {kecoRvwList.length > 0 ? : null} + {renderOpinionForm("KECO")} +
) : null} diff --git a/apps/as/src/shared/ui/tooltip.tsx b/apps/as/src/shared/ui/tooltip.tsx new file mode 100644 index 000000000..96151a3b5 --- /dev/null +++ b/apps/as/src/shared/ui/tooltip.tsx @@ -0,0 +1,58 @@ +"use client"; + +import * as React from "react"; + +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@common/components"; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/apps/as/src/widgets/pbadms-sprt/index.ts b/apps/as/src/widgets/pbadms-sprt/index.ts index 18508752a..858019e55 100644 --- a/apps/as/src/widgets/pbadms-sprt/index.ts +++ b/apps/as/src/widgets/pbadms-sprt/index.ts @@ -8,6 +8,7 @@ export { buildAcntgCostItems, recalculateBizCstTotals } from "./lib/business-cos export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-scale-form-utils"; export { buildChecklistTabs, + isFormChecklistComplete, orderCklstTreeNodes, syncCklstResultListFromTree, } from "./lib/checklist-form-utils"; diff --git a/apps/as/src/widgets/pbadms-sprt/lib/checklist-form-utils.ts b/apps/as/src/widgets/pbadms-sprt/lib/checklist-form-utils.ts index 8a19c47f9..f49782d84 100644 --- a/apps/as/src/widgets/pbadms-sprt/lib/checklist-form-utils.ts +++ b/apps/as/src/widgets/pbadms-sprt/lib/checklist-form-utils.ts @@ -159,3 +159,8 @@ export function syncCklstResultListFromTree( if (isSameCklstResultList(prev, next)) return prev; return next; } + +export function isFormChecklistComplete(cklstResultList: CklstResultListItem[]): boolean { + if (cklstResultList.length === 0) return true; + return cklstResultList.every((item) => item.selected === "Y" || item.selected === "N"); +} diff --git a/apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts b/apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts index 01c73fcea..fa0fc7908 100644 --- a/apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts +++ b/apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts @@ -190,7 +190,7 @@ export function validateBizCstSharBalance( ); if (exstnRemaining !== 0 || chgRqtRemaining !== 0) { - return "총사업비를 확인해주세요."; + return "재원분담 총사업비를 확인해주세요."; } return null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d914bd2bc..b9da50e6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,10 +104,10 @@ importers: devDependencies: eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -159,6 +159,9 @@ importers: '@hookform/resolvers': specifier: ^4.1.3 version: 4.1.3(react-hook-form@7.72.0(react@19.2.4)) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.75.5 version: 5.95.2(react@19.2.4) @@ -204,13 +207,13 @@ importers: version: 3.0.6 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-react-hooks: specifier: ^5.0.0 - version: 5.2.0(eslint@9.39.4(jiti@2.6.1)) + version: 5.2.0(eslint@9.39.4(jiti@1.21.7)) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -531,9 +534,6 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config - '@common/network': - specifier: workspace:* - version: link:../../packages/common/network '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.10