diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts deleted file mode 100644 index 97f925113..000000000 --- a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { client } from "@/shared/api"; - -export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise { - await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, { - atrzPrgrsSttsComCd, - }); -} 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 new file mode 100644 index 000000000..25991ab97 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts @@ -0,0 +1,102 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsResponseDto, + ElaprPrgrsSttsSaveRequestDto, + ElaprPrgrsSttsService, + ElaprProcessRequestDto, +} from "../model/elapr-prgrs-stts-interface"; + +const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS; + +async function findAllByDmndId(elaprDmndId?: string): Promise { + const response = await client.get(BASE, { + params: elaprDmndId ? { elaprDmndId } : undefined, + }); + return response.data; +} + +async function findById(elaprPrgrsId: string): Promise { + const response = await client.get(`${BASE}/${elaprPrgrsId}`); + return response.data; +} + +async function save(body: ElaprPrgrsSttsSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function process( + elaprPrgrsId: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/process`, + body, + ); + return response.data; +} + +async function processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/doc/${elaprDocNo}/aprvr/${aprvrUuid}/process`, + body, + ); + return response.data; +} + +async function deletePrgrsStts(elaprPrgrsId: string): Promise { + await client.delete(`${BASE}/${elaprPrgrsId}`); +} + +async function updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/approver`, + body, + ); + return response.data; +} + +async function insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, +): Promise { + const response = await client.post( + `${BASE}/${elaprDmndId}/approver/insert`, + body, + ); + return response.data; +} + +async function findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, +): Promise> { + const response = await client.get>(`${BASE}/my/doc`, { + params, + }); + return response.data; +} + +export const elaprPrgrsSttsService = { + findAllByDmndId, + findById, + save, + process, + processByDocNoAndAprvrUuid, + delete: deletePrgrsStts, + updateApprover, + insertApprover, + findMyElaperDocList, +} satisfies ElaprPrgrsSttsService; diff --git a/apps/as/src/entities/elapr/api/queries.ts b/apps/as/src/entities/elapr/api/queries.ts index fae6a0d03..8100a4d3f 100644 --- a/apps/as/src/entities/elapr/api/queries.ts +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -9,6 +9,13 @@ import type { ElaprAplnUpdateRequest, } from "../model/elapr-apln-interface"; import type { ElaprDmndSaveRequestDto, ElaprDmndSearchDto } from "../model/elapr-dmnd-interface"; +import type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsSaveRequestDto, + ElaprProcessRequestDto, +} from "../model/elapr-prgrs-stts-interface"; import type { ElaprTmpltSaveRequest, ElaprTmpltSearchParams, @@ -17,11 +24,18 @@ import type { import { elaprAplnService } from "./elapr-apln-service"; import { elaprDmndService } from "./elapr-dmnd-service"; +import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service"; import { elaprTmpltService } from "./elapr-tmplt-service"; const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const; const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const; const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const; +const elaprPrgrsSttsBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS] as const; + +function invalidateElaprPrgrsSttsQueries(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey }); + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); +} export function useElaprTmpltPage(params: ElaprTmpltSearchParams) { return useQuery({ @@ -230,3 +244,118 @@ export function useRemoveElaprDmnd() { }, }); } + +export function useElaprPrgrsSttsByDmndId(elaprDmndId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, "findAllByDmndId", elaprDmndId], + queryFn: () => elaprPrgrsSttsService.findAllByDmndId(elaprDmndId!), + enabled: Boolean(elaprDmndId), + }); +} + +export function useElaprPrgrsStts(elaprPrgrsId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, elaprPrgrsId], + queryFn: () => elaprPrgrsSttsService.findById(elaprPrgrsId!), + enabled: Boolean(elaprPrgrsId), + }); +} + +export function useElaprPrgrsSttsMyDocPage(params: ElaprPrgrsSttsMyDocSearchParams) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, "findMyElaperDocList", params], + queryFn: () => elaprPrgrsSttsService.findMyElaperDocList(params), + }); +} + +export function useSaveElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ElaprPrgrsSttsSaveRequestDto) => elaprPrgrsSttsService.save(body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useProcessElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprPrgrsId, + body, + }: { + elaprPrgrsId: string; + body: ElaprProcessRequestDto; + }) => elaprPrgrsSttsService.process(elaprPrgrsId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useProcessElaprPrgrsSttsByDoc() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprDocNo, + aprvrUuid, + body, + }: { + elaprDocNo: string; + aprvrUuid: string; + body: ElaprProcessRequestDto; + }) => elaprPrgrsSttsService.processByDocNoAndAprvrUuid(elaprDocNo, aprvrUuid, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useDeleteElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (elaprPrgrsId: string) => elaprPrgrsSttsService.delete(elaprPrgrsId), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useUpdateElaprPrgrsSttsApprover() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprPrgrsId, + body, + }: { + elaprPrgrsId: string; + body: ElaprApproverChangeRequestDto; + }) => elaprPrgrsSttsService.updateApprover(elaprPrgrsId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useInsertElaprPrgrsSttsApprover() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprDmndId, + body, + }: { + elaprDmndId: string; + body: ElaprApproverInsertRequestDto; + }) => elaprPrgrsSttsService.insertApprover(elaprDmndId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index 4b14ea0d9..42673c996 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -1,48 +1,91 @@ -export { elaprAplnService } from "./api/elapr-apln-service"; -export { elaprDmndService } from "./api/elapr-dmnd-service"; -export { elaprTmpltService } from "./api/elapr-tmplt-service"; -export { - useCreateElaprTmplt, - useDeleteElaprApln, - useDeleteElaprTmplt, - useElaprApln, - useElaprAplnPage, - useElaprDmnd, - useElaprDmndPage, - useElaprDocPrgrsByDocNo, - useElaprTmplt, - useElaprTmpltPage, - useRemoveElaprDmnd, - useReorderElaprApln, - useSaveElaprDmnd, - useSaveMultiElaprApln, - useUpdateElaprApln, - useUpdateElaprDmnd, - useUpdateElaprDmndStatus, - useUpdateElaprTmplt, -} from "./api/queries"; -export { - ELAPR_APLN_SAVE_MODE, - type ElaprApln, - type ElaprAplnListParams, - type ElaprAplnReorderItem, - type ElaprAplnReorderRequest, - type ElaprAplnSaveMode, - type ElaprAplnSaveRequest, - type ElaprAplnService, - type ElaprAplnUpdateRequest, -} from "./model/elapr-apln-interface"; -export type { - ElaprDmndResponseDto, - ElaprDmndSaveRequestDto, - ElaprDmndSearchDto, - ElaprDmndService, - ElaprDocPrgrsResponseDto, -} from "./model/elapr-dmnd-interface"; -export type { - ElaprTmplt, - ElaprTmpltSaveRequest, - ElaprTmpltSearchParams, - ElaprTmpltService, - ElaprTmpltUpdateRequest, -} from "./model/elapr-tmplt-interface"; +export { elaprAplnService } from "./api/elapr-apln-service"; +export { elaprDmndService } from "./api/elapr-dmnd-service"; +export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service"; +export { elaprTmpltService } from "./api/elapr-tmplt-service"; +export { + useCreateElaprTmplt, + useDeleteElaprApln, + useDeleteElaprPrgrsStts, + useDeleteElaprTmplt, + useElaprApln, + useElaprAplnPage, + useElaprDmnd, + useElaprDmndPage, + useElaprDocPrgrsByDocNo, + useElaprPrgrsStts, + useElaprPrgrsSttsByDmndId, + useElaprPrgrsSttsMyDocPage, + useElaprTmplt, + useElaprTmpltPage, + useInsertElaprPrgrsSttsApprover, + useProcessElaprPrgrsStts, + useProcessElaprPrgrsSttsByDoc, + useRemoveElaprDmnd, + useReorderElaprApln, + useSaveElaprDmnd, + useSaveElaprPrgrsStts, + useSaveMultiElaprApln, + useUpdateElaprApln, + useUpdateElaprDmnd, + useUpdateElaprDmndStatus, + useUpdateElaprPrgrsSttsApprover, + useUpdateElaprTmplt, +} from "./api/queries"; +export { + assignAtrzSeqNumbers, + buildSaveMultiPayload, + hasUnsavedChanges, +} from "./lib/apln-save-utils"; +export { + getVisibleRows, + validateAllRowsForSave, + validateAplnRow, + validateAtrzOrder, + validateUniqueSeqNo, +} from "./lib/apln-validators"; +export { + buildElaprPrgrsSttsListFromApln, + buildElaprPrgrsSttsListFromAplnRows, +} from "./lib/dmnd-save-utils"; +export { + findCurrentUserPendingApprovalRequest, + hasCurrentUserPendingApprovalRequest, +} from "./lib/approval-request-utils"; +export { createDraftRowKey, useAplnRows } from "./lib/use-apln-rows"; +export { useDndSensors } from "./lib/use-dnd-sensors"; +export { + ELAPR_APLN_SAVE_MODE, + type ElaprApln, + type ElaprAplnListParams, + type ElaprAplnReorderItem, + type ElaprAplnReorderRequest, + type ElaprAplnSaveMode, + type ElaprAplnSaveRequest, + type ElaprAplnService, + type ElaprAplnUpdateRequest, +} from "./model/elapr-apln-interface"; +export type { ElaprAplnRow } from "./model/elapr-apln-row-types"; +export type { + ElaprDmndResponseDto, + ElaprDmndSaveRequestDto, + ElaprDmndSearchDto, + ElaprDmndService, + ElaprDocPrgrsResponseDto, +} from "./model/elapr-dmnd-interface"; +export type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsResponseDto, + ElaprPrgrsSttsSaveRequestDto, + ElaprPrgrsSttsSearchDto, + ElaprPrgrsSttsService, + ElaprProcessRequestDto, +} from "./model/elapr-prgrs-stts-interface"; +export type { + ElaprTmplt, + ElaprTmpltSaveRequest, + ElaprTmpltSearchParams, + ElaprTmpltService, + ElaprTmpltUpdateRequest, +} from "./model/elapr-tmplt-interface"; diff --git a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts b/apps/as/src/entities/elapr/lib/apln-save-utils.ts similarity index 57% rename from apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts rename to apps/as/src/entities/elapr/lib/apln-save-utils.ts index 0c66b7623..17974a166 100644 --- a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts +++ b/apps/as/src/entities/elapr/lib/apln-save-utils.ts @@ -1,8 +1,16 @@ -import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr"; +import { + ELAPR_APLN_SAVE_MODE, + type ElaprApln, + type ElaprAplnSaveRequest, +} from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; -import type { ElaprAplnRow } from "../model/apln-row-types"; +import { getVisibleRows } from "./apln-validators"; + +/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */ +export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] { + return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); +} function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean { return ( @@ -22,6 +30,25 @@ function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolea return rowDiffersFromServer(row, serverRow); } +export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean { + if (rows.some((row) => row.isDeleted && row.aplnId)) return true; + if (rows.some((row) => row.isNew && !row.isDeleted)) return true; + + const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) continue; + if (!row.aplnId) continue; + if (row.isDirty) return true; + + const serverRow = serverRows.find((s) => s.aplnId === row.aplnId); + if (!serverRow) continue; + if (rowDiffersFromServer(row, serverRow)) return true; + } + + return false; +} + export function buildSaveMultiPayload( rows: ElaprAplnRow[], elaprTmpltId: string, diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/entities/elapr/lib/apln-validators.ts similarity index 94% rename from apps/as/src/pages/approval-line-management/lib/validators.ts rename to apps/as/src/entities/elapr/lib/apln-validators.ts index 4e301afa9..adfe167db 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/entities/elapr/lib/apln-validators.ts @@ -1,4 +1,4 @@ -import type { ElaprAplnRow } from "../model/apln-row-types"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; const ATRZ_RECEIVER_CD = "ATRZ01"; const ATRZ_FINAL_CD = "ATRZ99"; diff --git a/apps/as/src/entities/elapr/lib/approval-request-utils.ts b/apps/as/src/entities/elapr/lib/approval-request-utils.ts new file mode 100644 index 000000000..db9cae9dc --- /dev/null +++ b/apps/as/src/entities/elapr/lib/approval-request-utils.ts @@ -0,0 +1,25 @@ +import type { ElaprDocPrgrsResponseDto } from "../model/elapr-dmnd-interface"; + +export function hasCurrentUserPendingApprovalRequest( + list: ElaprDocPrgrsResponseDto[], + currentUserUuid: string | undefined, +): boolean { + if (!currentUserUuid) return false; + + return list.some( + (row) => + row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + ); +} + +export function findCurrentUserPendingApprovalRequest( + list: ElaprDocPrgrsResponseDto[], + currentUserUuid: string | undefined, +): ElaprDocPrgrsResponseDto | undefined { + if (!currentUserUuid) return undefined; + + return list.find( + (row) => + row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + ); +} diff --git a/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts new file mode 100644 index 000000000..7eee2894d --- /dev/null +++ b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts @@ -0,0 +1,43 @@ +import type { ElaprApln } from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; +import type { ElaprPrgrsSttsSaveRequestDto } from "../model/elapr-prgrs-stts-interface"; + +import { getVisibleRows } from "./apln-validators"; + +function toPrgrsSttsItem( + aprvrUuid: string | undefined, + atrzSeComCd: string | undefined, + atrzSeqNo: number | undefined, +): ElaprPrgrsSttsSaveRequestDto { + if (!aprvrUuid?.trim()) { + throw new Error("결재자가 지정되지 않은 행이 있습니다."); + } + if (!atrzSeComCd?.trim()) { + throw new Error("결재구분이 지정되지 않은 행이 있습니다."); + } + if (atrzSeqNo == null) { + throw new Error("결재순서번호가 지정되지 않은 행이 있습니다."); + } + + return { + aprvrUuid, + atrzSeComCd, + atrzSeqNo, + }; +} + +/** 서식 결재선 API 응답 → 기안 요청용 진행상태 목록 */ +export function buildElaprPrgrsSttsListFromApln( + items: ElaprApln[], +): ElaprPrgrsSttsSaveRequestDto[] { + return items.map((item) => toPrgrsSttsItem(item.aprvrUuid, item.atrzSeComCd, item.atrzSeqNo)); +} + +/** 결재선 그리드 행 → 기안 요청용 진행상태 목록 (서버가 seq 기준 상태 자동 결정) */ +export function buildElaprPrgrsSttsListFromAplnRows( + rows: ElaprAplnRow[], +): ElaprPrgrsSttsSaveRequestDto[] { + return getVisibleRows(rows).map((row) => + toPrgrsSttsItem(row.aprvrUuid, row.atrzSeComCd, row.atrzSeqNo), + ); +} diff --git a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts b/apps/as/src/entities/elapr/lib/use-apln-rows.ts similarity index 80% rename from apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts rename to apps/as/src/entities/elapr/lib/use-apln-rows.ts index 01c71ce3b..384c8ffc1 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts +++ b/apps/as/src/entities/elapr/lib/use-apln-rows.ts @@ -4,12 +4,11 @@ import { useCallback, useEffect, useState } from "react"; import { arrayMove } from "@dnd-kit/sortable"; -import type { ElaprApln } from "@/entities/elapr"; +import type { ElaprApln } from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -import type { ElaprAplnRow } from "../model/apln-row-types"; - -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; +import { assignAtrzSeqNumbers } from "./apln-save-utils"; +import { getVisibleRows } from "./apln-validators"; function toRow(item: ElaprApln): ElaprAplnRow { return { @@ -101,7 +100,13 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { 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; + const { + snapshot: _s, + isNew: _n, + isDirty: _d, + isDeleted: _del, + ...restored + } = row.snapshot; return { ...restored, isNew: false, isDirty: false, isDeleted: false }; } return { ...row, isDirty: false }; @@ -110,19 +115,16 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { 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 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) => { @@ -158,7 +160,10 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { 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))]; + const merged = [ + ...reorderedVisible, + ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)), + ]; payloadRows = reorderedVisible; return merged; diff --git a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts similarity index 93% rename from apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts rename to apps/as/src/entities/elapr/lib/use-dnd-sensors.ts index 7c4e82ba9..039414303 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts +++ b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts @@ -1,3 +1,5 @@ +"use client"; + import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; diff --git a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts similarity index 87% rename from apps/as/src/pages/approval-line-management/model/apln-row-types.ts rename to apps/as/src/entities/elapr/model/elapr-apln-row-types.ts index cabc7bb08..b3532df8a 100644 --- a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts @@ -1,4 +1,4 @@ -import type { ElaprApln } from "@/entities/elapr"; +import type { ElaprApln } from "./elapr-apln-interface"; /** 그리드 로컬 행 — API 응답 + UI 보조 필드 */ export interface ElaprAplnRow extends Omit { diff --git a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts index f653249f2..5a3fbc689 100644 --- a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts @@ -1,11 +1,13 @@ import type { PageableParams, PageResponse } from "@/shared/model"; +import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface"; + /** 백엔드 ElaprDmndSaveRequestDto */ export interface ElaprDmndSaveRequestDto { elaprDocNo: string; - elaprTmpltId: string; dmndTtl: string; dmndCn: string; + elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[]; } /** 백엔드 ElaprDmndResponseDto */ @@ -13,7 +15,6 @@ export interface ElaprDmndResponseDto { elaprDmndId: string; rqstrUuid?: string; elaprDocNo: string; - elaprTmpltId: string; dmndTtl: string; dmndCn: string; atrzSttsComCd: string; 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 new file mode 100644 index 000000000..2ab1c5e14 --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts @@ -0,0 +1,78 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +/** 백엔드 ElaprPrgrsSttsResponseDto */ +export interface ElaprPrgrsSttsResponseDto { + elaprPrgrsId: string; + elaprDmndId?: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeComNm?: string; + atrzSeqNo?: number; + atrzPrgrsSttsComCd?: string; + atrzPrgrsSttsComNm?: string; + rjctRsn?: string; + atrzDt?: string; +} + +/** 백엔드 ElaprPrgrsSttsSaveRequestDto */ +export interface ElaprPrgrsSttsSaveRequestDto { + elaprDmndId?: string; + aprvrUuid: string; + atrzSeComCd: string; + atrzSeqNo: number; + atrzPrgrsSttsComCd?: string; +} + +/** 백엔드 ElaprPrgrsSttsSearchDto */ +export interface ElaprPrgrsSttsSearchDto { + atrzSeComCd: string; + atrzSeqNo: number; + atrzPrgrsSttsComCd?: string; +} + +/** 백엔드 ElaprPrgrsSttsSearchDto + Pageable */ +export interface ElaprPrgrsSttsMyDocSearchParams extends ElaprPrgrsSttsSearchDto, PageableParams {} + +/** 백엔드 ElaprProcessRequestDto */ +export interface ElaprProcessRequestDto { + atrzPrgrsSttsComCd: string; + rjctRsn?: string; + finalApprovalYn?: boolean; +} + +/** 백엔드 ElaprApproverChangeRequestDto */ +export interface ElaprApproverChangeRequestDto { + aprvrUuid: string; +} + +/** 백엔드 ElaprApproverInsertRequestDto */ +export interface ElaprApproverInsertRequestDto { + aprvrUuid: string; + atrzSeComCd: string; + atrzSeqNo: number; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface ElaprPrgrsSttsService { + findAllByDmndId(elaprDmndId?: string): Promise; + findById(elaprPrgrsId: string): Promise; + save(body: ElaprPrgrsSttsSaveRequestDto): Promise; + process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise; + processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, + ): Promise; + delete(elaprPrgrsId: string): Promise; + updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, + ): Promise; + insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, + ): Promise; + findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, + ): Promise>; +} diff --git a/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts b/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts deleted file mode 100644 index 6b5b63bd4..000000000 --- a/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ElaprAplnRow } from "../model/apln-row-types"; - -/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */ -export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] { - return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); -} diff --git a/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts b/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts deleted file mode 100644 index 46efa59f3..000000000 --- a/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ElaprApln } from "@/entities/elapr"; - -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; -import type { ElaprAplnRow } from "../model/apln-row-types"; - -function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean { - return ( - row.aprvrUuid !== serverRow.aprvrUuid || - row.atrzSeComCd !== serverRow.atrzSeComCd || - row.atrzSeqNo !== serverRow.atrzSeqNo - ); -} - -export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean { - if (rows.some((row) => row.isDeleted && row.aplnId)) return true; - if (rows.some((row) => row.isNew && !row.isDeleted)) return true; - - const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows)); - - for (const row of visibleWithSeq) { - if (row.isNew) continue; - if (!row.aplnId) continue; - if (row.isDirty) return true; - - const serverRow = serverRows.find((s) => s.aplnId === row.aplnId); - if (!serverRow) continue; - if (rowDiffersFromServer(row, serverRow)) return true; - } - - return false; -} diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx index c2393715b..83c01c7ab 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx @@ -7,7 +7,19 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable" import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useAlertStore } from "@/shared/lib"; -import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr"; +import { + buildSaveMultiPayload, + type ElaprAplnRow, + hasUnsavedChanges, + useAplnRows, + useDndSensors, + useElaprAplnPage, + useSaveMultiElaprApln, + validateAllRowsForSave, + validateAplnRow, + validateAtrzOrder, + validateUniqueSeqNo, +} from "@/entities/elapr"; import { Button, Skeleton, @@ -18,17 +30,6 @@ import { TableRow, } from "@common/components"; -import { buildSaveMultiPayload } from "../lib/build-save-multi-payload"; -import { hasUnsavedChanges } from "../lib/has-unsaved-changes"; -import { useAplnRows } from "../lib/use-apln-rows"; -import { useDndSensors } from "../lib/use-dnd-sensors"; -import { - validateAllRowsForSave, - validateAplnRow, - validateAtrzOrder, - validateUniqueSeqNo, -} from "../lib/validators"; -import type { ElaprAplnRow } from "../model/apln-row-types"; import type { ReviewerOption } from "../model/reviewer-option"; import { AprvrPickerDialog } from "./aprvr-picker-dialog"; diff --git a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx index fcb315bdb..94d28be22 100644 --- a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx +++ b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx @@ -5,10 +5,9 @@ import { CSS } from "@dnd-kit/utilities"; import { Equal } from "lucide-react"; import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code"; +import type { ElaprAplnRow } from "@/entities/elapr"; import { Button, TableCell, TableRow } from "@common/components"; -import type { ElaprAplnRow } from "../model/apln-row-types"; - interface SortableAplnRowProps { row: ElaprAplnRow; displaySeqNo: number; diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts index 3632b2a18..cc6d8d4b9 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts @@ -74,10 +74,19 @@ function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltn return actions.map((action) => FNRS_CNSLTN_VIEW_BUTTON_ACTION[action]); } +interface GetButtonConfigsOptions { + hasPendingApprovalRequest?: boolean; +} + function resolveRoleUser01AtrzButtonActions( prgrsSttsComCd: string, atrzSeComCdList: string[], + options?: GetButtonConfigsOptions, ): FnrsCnsltnViewButtonAction[] { + if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) { + return []; + } + const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd]; if (!atrzStatusMap || atrzSeComCdList.length === 0) return []; @@ -114,12 +123,13 @@ export function getButtonConfigs( dtlInstCd: string | undefined, prgrsSttsComCd: string, atrzSeComCdList?: string[], + options?: GetButtonConfigsOptions, ): FnrsCnsltnViewButtonAction[] { if (!dtlInstCd) return []; if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) { return resolveButtonConfigs( - resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? []), + resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options), ); } diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts index 807ab30a4..d76887394 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts @@ -1,4 +1,4 @@ -import type { IUserInfo } from "@/shared/store/user-store"; +import type { IUserInfo } from "@/shared/store"; import type { FnrsCnsltnMngDetResponseDto } from "@/entities/fnrs-cnsltn"; import { mapFnrsSharDetToFormList } from "@/widgets/pbadms-sprt"; @@ -83,9 +83,7 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl bgtRfltAmt: item.bgtRfltAmt, tstmDesignAmt: item.tstmDesignAmt, })), - fnrsSharList: data.fnrsSharList?.length - ? mapFnrsSharDetToFormList(data.fnrsSharList) - : [], + fnrsSharList: data.fnrsSharList?.length ? mapFnrsSharDetToFormList(data.fnrsSharList) : [], cklstResultList: cklstResultList?.map((item) => ({ cklstSn: item.cklstSn, diff --git a/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts new file mode 100644 index 000000000..2c9a72ef8 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts @@ -0,0 +1,5 @@ +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx new file mode 100644 index 000000000..dd241a514 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { RvwMnpwMngResponseDto, useRvwMnpwMngPage } from "@/entities/rvw-mnpw"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { type ReviewerOption } from "../../model/reviewer-option"; + +const PAGE_SIZE = 500; + +interface AprvrPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + atrzSeComCd: string; + onSelect: (reviewer: ReviewerOption) => void; +} + +function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrPickerDialogProps) { + const { data, isPending, isError } = useRvwMnpwMngPage( + { + atrzSeComCd, + page: 0, + size: PAGE_SIZE, + sort: "userUuidNm,asc", + }, + { enabled: open }, + ); + + const items = data?.content ?? []; + + function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption { + return { + uuid: item.userUuid, + name: item.userUuidNm ?? "", + deptNm: item.ognzNm ?? "", + }; + } + + function handleSelect(item: (typeof items)[number]) { + onSelect(fromRvwMnpwMng(item)); + onOpenChange(false); + } + + return ( + + + + 검토인력 선택 + + +
+ {isPending ? ( +
+ + +
+ ) : isError ? ( +

+ 검토인력 목록을 불러오지 못했습니다. +

+ ) : items.length === 0 ? ( +

+ 배치된 검토인력이 없습니다. +

+ ) : ( + + + + + + + + + + + 성명 + 부서 + 직위 + 직급 + 선택 + + + + {items.map((item) => ( + + {item.userUuidNm || item.userUuid} + {item.ognzNm || "-"} + {item.jbpsSeNm || "-"} + {item.jbgdSeNm || "-"} + + + + + ))} + +
+ )} +
+ + + + +
+
+ ); +} + +export { AprvrPickerDialog }; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx new file mode 100644 index 000000000..a22a2e880 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; + +import { useAlertStore } from "@/shared/lib"; +import { + type ElaprAplnRow, + useAplnRows, + useDndSensors, + useElaprAplnPage, + validateAplnRow, +} from "@/entities/elapr"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import type { ReviewerOption } from "../../model/reviewer-option"; + +import { AprvrPickerDialog } from "./aprvr-picker-dialog"; +import { SortableAplnPickerRow } from "./sortable-apln-picker-row"; + +interface ElaprAplnPickerGridProps { + elaprTmpltId: string | null; + onRowsChange?: (rows: ElaprAplnRow[]) => void; + onEditingLockedChange?: (locked: boolean) => void; +} + +function ElaprAplnPickerGrid({ + elaprTmpltId, + onRowsChange, + onEditingLockedChange, +}: ElaprAplnPickerGridProps) { + const [pickerOpen, setPickerOpen] = useState(false); + const [pickingRowKey, setPickingRowKey] = useState(null); + + const alert = useAlertStore((state) => state.setMessage); + + const aplnQuery = useElaprAplnPage({ + elaprTmpltId: elaprTmpltId ?? undefined, + page: 0, + size: 200, + sort: "atrzSeqNo,asc", + }); + + const { + rows, + visibleRows, + editingRowKey, + isEditingLocked, + addRow, + startEdit, + updateRow, + cancelEdit, + commitEdit, + markDeleted, + reorderRows, + } = useAplnRows({ + elaprTmpltId, + serverRows: aplnQuery.data?.content, + }); + + useEffect(() => { + onRowsChange?.(rows); + }, [rows, onRowsChange]); + + useEffect(() => { + onEditingLockedChange?.(isEditingLocked); + }, [isEditingLocked, onEditingLockedChange]); + + const sensors = useDndSensors(); + const isDisabled = !elaprTmpltId; + const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined; + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + reorderRows(String(active.id), String(over.id)); + } + + function handleRowChange(row: ElaprAplnRow, patch: Partial) { + updateRow(row.rowKey, patch); + } + + function handleRowSave(row: ElaprAplnRow) { + const fieldError = validateAplnRow(row); + if (fieldError) { + alert(fieldError); + return; + } + + commitEdit(row.rowKey); + } + + function handleRowDelete(row: ElaprAplnRow) { + markDeleted(row.rowKey); + } + + function handleReviewerSelect(reviewer: ReviewerOption) { + if (!pickingRowKey) return; + updateRow(pickingRowKey, { + aprvrUuid: reviewer.uuid, + aprvrNm: reviewer.name, + }); + setPickingRowKey(null); + } + + return ( +
+
+

결재선 관리

+
+ +
+
+ + {aplnQuery.isPending && elaprTmpltId ? ( +
+ + +
+ ) : !elaprTmpltId ? ( +

결재서식을 선택하세요.

+ ) : visibleRows.length === 0 ? ( +

+ 등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요. +

+ ) : ( + + + + + + + + + + + + + + 결재선아이디 + 결재순서 + 결재구분 + 결재자 + 관리 + + + + r.rowKey)} + strategy={verticalListSortingStrategy} + > + {visibleRows.map((row, index) => ( + startEdit(row.rowKey)} + onSave={() => handleRowSave(row)} + onCancel={cancelEdit} + onDelete={() => handleRowDelete(row)} + onChange={(patch) => handleRowChange(row, patch)} + onPickAprvr={() => { + setPickingRowKey(row.rowKey); + setPickerOpen(true); + }} + /> + ))} + + +
+
+ )} + + +
+ ); +} + +export { ElaprAplnPickerGrid }; 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 31fed4ccc..da4480f6c 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 @@ -7,8 +7,7 @@ import { AxiosError } from "axios"; import { ENDPOINT } from "@/shared/config"; import { useAlertStore } from "@/shared/lib"; -import { useElaprDocPrgrsByDocNo } from "@/entities/elapr"; -import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service"; +import { elaprPrgrsSttsService, findCurrentUserPendingApprovalRequest, useElaprDocPrgrsByDocNo } from "@/entities/elapr"; import { Button, cn, @@ -55,8 +54,9 @@ function ElaprApprovalDialog({ const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList); - const currentUserPrgrs = elaprDocPrgrsList.find( - (row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + const currentUserPrgrs = findCurrentUserPendingApprovalRequest( + elaprDocPrgrsList, + currentUserUuid, ); const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId); @@ -65,7 +65,9 @@ function ElaprApprovalDialog({ setIsProcessing(true); try { - await processElaprPrgrs(currentUserPrgrs.elaprPrgrsId, "ATRZ_PRGRS_003"); + await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, { + atrzPrgrsSttsComCd: "ATRZ_PRGRS_003", + }); alert("결재가 완료되었습니다."); queryClient.invalidateQueries({ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo], @@ -94,7 +96,9 @@ function ElaprApprovalDialog({ setIsProcessing(true); try { - await processElaprPrgrs(currentUserPrgrs.elaprPrgrsId, "ATRZ_PRGRS_004"); + await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, { + atrzPrgrsSttsComCd: "ATRZ_PRGRS_004", + }); alert("반려 처리가 완료되었습니다."); queryClient.invalidateQueries({ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo], diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx index 8ad20fa95..3f59f602b 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx @@ -1,137 +1,163 @@ -"use client"; - -import { Fragment } from "react"; - -import { type ElaprTmplt, useElaprAplnPage, useElaprTmpltPage } from "@/entities/elapr"; -import { - Badge, - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Skeleton, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@common/components"; - -const PAGE_SIZE = 100; - -interface ElaprTmpltPickerDialogProps { - open: boolean; - onClose: () => void; - onSelect: (tmplt: ElaprTmplt) => void; -} - -function ElaprTmpltPickerDialog({ open, onClose, onSelect }: ElaprTmpltPickerDialogProps) { - const { data, isPending, isError } = useElaprTmpltPage({ - page: 0, - size: PAGE_SIZE, - sort: "regDt,desc", - docSeComCd: "FNRS", - }); - - const items = data?.content ?? []; - - function handleSelect(tmplt: ElaprTmplt) { - onSelect(tmplt); - onClose(); - } - - return ( - - - - 결재선 선택 - - -
- {isPending ? ( -
- - -
- ) : isError ? ( -

- 결재서식 목록을 불러오지 못했습니다. -

- ) : items.length === 0 ? ( -

- 선택 가능한 결재서식이 없습니다. -

- ) : ( - - - - - - - - - - 결재서식명 - 서식 설명 - 검토자 - 선택 - - - - {items.map((item) => ( - - {item.elaprTmpltNm} - {item.elaprTmpltExpln || "-"} - - - - - - - - ))} - -
- )} -
- - - - -
-
- ); -} - -function ElaprAplnCell({ elaprTmpltId }: { elaprTmpltId: string }) { - const { data } = useElaprAplnPage({ - elaprTmpltId, - page: 0, - size: PAGE_SIZE, - sort: "atrzSeqNo,asc", - }); - - const items = data?.content ?? []; - - return ( -
- {items.map((item) => ( - -
- {item.atrzSeComNm} - {item.aprvrNm} -
-
- ))} -
- ); -} - -export { ElaprTmpltPickerDialog }; +"use client"; + +import { useCallback, useState } from "react"; + +import { useAlertStore } from "@/shared/lib"; +import { + type ElaprAplnRow, + type ElaprTmplt, + getVisibleRows, + useElaprTmpltPage, + validateAllRowsForSave, + validateAtrzOrder, + validateUniqueSeqNo, +} from "@/entities/elapr"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@common/components"; + +import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid"; +import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table"; + +const PAGE_SIZE = 100; + +interface ElaprTmpltPickerConfirmPayload { + tmplt: ElaprTmplt; + aplnRows: ElaprAplnRow[]; +} + +interface ElaprTmpltPickerDialogProps { + open: boolean; + onClose: () => void; + onSelect?: (tmplt: ElaprTmplt) => void; + onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void; +} + +function ElaprTmpltPickerDialog({ + open, + onClose, + onSelect, + onConfirm, +}: ElaprTmpltPickerDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + const [selectedTmplt, setSelectedTmplt] = useState(null); + const [aplnRows, setAplnRows] = useState([]); + const [isEditingLocked, setIsEditingLocked] = useState(false); + + const tmpltQuery = useElaprTmpltPage({ + page: 0, + size: PAGE_SIZE, + sort: "regDt,desc", + docSeComCd: "FNRS", + }); + + const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => { + setAplnRows(rows); + }, []); + + const handleEditingLockedChange = useCallback((locked: boolean) => { + setIsEditingLocked(locked); + }, []); + + function handleSelectTmplt(tmplt: ElaprTmplt | null) { + setSelectedTmplt(tmplt); + setAplnRows([]); + setIsEditingLocked(false); + } + + function handleOpenChange(isOpen: boolean) { + if (!isOpen) { + setSelectedTmplt(null); + setAplnRows([]); + setIsEditingLocked(false); + onClose(); + } + } + + function handleConfirm() { + if (!selectedTmplt) { + alert("결재서식을 선택해 주세요."); + return; + } + + const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked); + if (rowsError) { + alert(rowsError); + return; + } + + const orderError = validateAtrzOrder(aplnRows); + if (orderError) { + alert(orderError); + return; + } + + const seqError = validateUniqueSeqNo(aplnRows); + if (seqError) { + alert(seqError); + return; + } + + const visibleRows = getVisibleRows(aplnRows); + + const payload: ElaprTmpltPickerConfirmPayload = { + tmplt: selectedTmplt, + aplnRows: visibleRows, + }; + + if (onConfirm) { + onConfirm(payload); + } else { + onSelect?.(selectedTmplt); + } + + setSelectedTmplt(null); + setAplnRows([]); + setIsEditingLocked(false); + onClose(); + } + + return ( + + + + 결재선 선택 + + +
+ + +
+ + + + + +
+
+ ); +} + +export { ElaprTmpltPickerDialog }; +export type { ElaprTmpltPickerConfirmPayload }; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx new file mode 100644 index 000000000..50bdb33b9 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import type { UseQueryResult } from "@tanstack/react-query"; +import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@tanstack/react-table"; + +import { useDataTable } from "@/shared/lib"; +import type { PageResponse } from "@/shared/model"; +import type { ElaprTmplt } from "@/entities/elapr"; +import { Skeleton } from "@common/components"; + +const columnHelper = createColumnHelper(); + +interface ElaprTmpltSelectTableProps { + query: UseQueryResult>; + selectedTmpltId: string | null; + onSelectTmplt: (tmplt: ElaprTmplt | null) => void; +} + +function ElaprTmpltSelectTable({ + query, + selectedTmpltId, + onSelectTmplt, +}: ElaprTmpltSelectTableProps) { + const [rowSelection, setRowSelection] = useState({}); + + useEffect(() => { + if (selectedTmpltId) { + setRowSelection({ [selectedTmpltId]: true }); + } else { + setRowSelection({}); + } + }, [selectedTmpltId]); + + const columns = useMemo( + () => [ + columnHelper.accessor("elaprTmpltId", { + header: "전자결재서식아이디", + meta: { align: "left" }, + }), + columnHelper.accessor("elaprTmpltNm", { + header: "결재서식명", + meta: { align: "left" }, + }), + columnHelper.accessor("docSeComNm", { + header: "문서구분", + cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—", + }), + ], + [], + ); + + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns, + state: { rowSelection }, + enableRowSelection: true, + enableMultiRowSelection: false, + getRowId: (row) => row.elaprTmpltId, + onRowSelectionChange: (updater) => { + const next = typeof updater === "function" ? updater(rowSelection) : updater; + setRowSelection(next); + const selectedId = Object.keys(next)[0]; + if (!selectedId) { + onSelectTmplt(null); + return; + } + const selected = query.data?.content.find((t) => t.elaprTmpltId === selectedId); + onSelectTmplt(selected ?? null); + }, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + onRowClick: (row) => { + setRowSelection({ [row.elaprTmpltId]: true }); + onSelectTmplt(row); + }, + }, + }); + + return ( +
+
+

전자결재서식

+
+ {query.isPending && ( +
+ + +
+ )} + {query.isError && ( +

+ 결재서식 목록을 불러오지 못했습니다. +

+ )} + {!query.isPending && !query.isError && tableComponent} +
+ ); +} + +export { ElaprTmpltSelectTable }; 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 00e75150f..5a5c87a44 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 @@ -1,640 +1,672 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; - -import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { useQueryClient } from "@tanstack/react-query"; -import { AxiosError } from "axios"; - -import { useAlertStore, useUserStore } from "@/shared/lib"; -import { type ElaprDmndSaveRequestDto, elaprDmndService, type ElaprTmplt } from "@/entities/elapr"; -import { - type DocPrgrsUpdateRequestDto, - type FnrsCnsltnApplySaveRequestDto, - fnrsCnsltnMngBaseKey, - type FnrsCnsltnMngDetResponseDto, - fnrsCnsltnMngService, -} from "@/entities/fnrs-cnsltn"; -import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw"; -import { TextInputDialog } from "@/widgets/pbadms-sprt"; -import { Button } from "@common/components"; - -import { - FNRS_CNSLTN_VIEW_BUTTON_ACTION, - type FnrsCnsltnViewButtonAction, - getButtonConfigs, - needsRoleUser01AtrzResolve, -} from "../../lib/button-config"; -import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils"; -import { - buildSavePayload, - buildSupplementReqPayload, - buildSupplementSubmitPayload, - buildTechReviewReplyPayload, -} from "../../lib/save-payload-utils"; -import { type ChecklistFormState } from "../../model/view-types"; -import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section"; - -import { ElaprApprovalDialog } from "./elapr-approval-dialog"; -import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog"; - -interface FloatingActionButtonsProps { - data: FnrsCnsltnMngDetResponseDto; - docSeComCd: string; - fnrsCnsltnDocNo: string; - prgrsSttsComCd: string; - dtlInstCd: string | undefined; - checklistFormState: ChecklistFormState; - techReviewOpinionRef?: React.RefObject; - opnrNm?: string; -} - -type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null; - -function FloatingActionButtons({ - data, - docSeComCd, - fnrsCnsltnDocNo, - prgrsSttsComCd, - dtlInstCd, - checklistFormState, - techReviewOpinionRef, - opnrNm, -}: FloatingActionButtonsProps) { - const router = useRouter(); - const { confirm } = useConfirm(); - const queryClient = useQueryClient(); - const userInfo = useUserStore((state) => state.userInfo); - const alert = useAlertStore((state) => state.setMessage); - const [isProcessing, setIsProcessing] = useState(false); - const [activeDialog, setActiveDialog] = useState(null); - - const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo; - const isRegister = data.detDto?.rgtr === userInfo?.userUnqId; - - const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd); - const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList( - {}, - { enabled: needsAtrzResolve }, - ); - - const atrzSeComCdList = useMemo( - () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [], - [rvwLoginUserData], - ); - - const actionButtons = useMemo(() => { - if (needsAtrzResolve && isRvwLoginUserLoading) return []; - return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList); - }, [atrzSeComCdList, dtlInstCd, isRvwLoginUserLoading, needsAtrzResolve, prgrsSttsComCd]); - - const canEdit = - isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg); - - function handleGoToList() { - router.push("/fnrs-cnsltn"); - } - - function handleEdit() { - router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`); - } - - function shouldShowButton(action: FnrsCnsltnViewButtonAction) { - return actionButtons.some((button) => button === action); - } - - async function executeAction({ - action, - request, - successMessage, - }: { - action: FnrsCnsltnViewButtonAction; - request?: unknown; - successMessage?: string; - }) { - const handler = buttonActionHandlers[action]; - if (!handler) return; - - setIsProcessing(true); - try { - await handler({ - request, - data, - onSuccess: () => { - alert(successMessage ?? "처리가 완료되었습니다."); - queryClient.invalidateQueries({ - queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], - }); - }, - onError: (error) => { - let errorMessage = ""; - if (error instanceof AxiosError) { - errorMessage = error.response?.data?.message ?? ""; - } else { - errorMessage = error.message ?? "처리 중 오류가 발생했습니다."; - } - alert(errorMessage); - }, - }); - } finally { - setIsProcessing(false); - } - } - - async function handleStrgClick() { - const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload( - data, - checklistFormState, - "LOCAL", - "SV", - ); - - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg, - request: payload, - successMessage: "저장되었습니다.", - }); - } - - async function handleAplyClick() { - const { percent } = computeReviewStepChecklistProgress( - data.cklstResultList ?? [], - checklistFormState.results, - "LOCAL", - ); - const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload( - data, - checklistFormState, - "LOCAL", - "ST", - ); - - if (percent !== 100) { - alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다."); - return; - } - - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply, - request: payload, - successMessage: "신청 완료되었습니다.", - }); - queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); - } - - async function handleDelClick() { - if (!(await confirm("삭제하시겠습니까?"))) return; - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del, - successMessage: "삭제되었습니다.", - }); - router.push("/fnrs-cnsltn"); - } - - async function handleFnrsCnsltnAncmntClick() { - const payload: DocPrgrsUpdateRequestDto = { - docNo, - docSeComCd, - prgrsSttsComCd: "TB", - }; - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt, - request: payload, - successMessage: "고시되었습니다.", - }); - queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); - } - - async function handleEnvoSbmsnClick() { - const payload: DocPrgrsUpdateRequestDto = { - docNo, - docSeComCd, - prgrsSttsComCd: "LE", - }; - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, - request: payload, - successMessage: "제출되었습니다.", - }); - queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); - } - - async function handleRjctClick() { - const payload: DocPrgrsUpdateRequestDto = { - docNo, - docSeComCd, - prgrsSttsComCd: "SV", - }; - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct, - request: payload, - successMessage: "반려되었습니다.", - }); - queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); - } - - async function handleSncnAprvClick() { - const payload: DocPrgrsUpdateRequestDto = { - docNo, - docSeComCd, - prgrsSttsComCd: "TA", - }; - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv, - request: payload, - successMessage: "인가승인되었습니다.", - }); - queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); - } - - function getReviewOpinionFormData() { - return techReviewOpinionRef?.current?.getFormData() ?? null; - } - - async function handleRvwRplyClick() { - const { percent } = computeReviewStepChecklistProgress( - data.cklstResultList ?? [], - checklistFormState.results, - "ENV", - ); - - if (percent !== 100) { - alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다."); - return; - } - - const payload = buildTechReviewReplyPayload( - data, - checklistFormState, - "ENV", - "EP", - "CP", - getReviewOpinionFormData(), - opnrNm, - ); - - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, - request: payload, - successMessage: "검토결과 회신이 완료되었습니다.", - }); - techReviewOpinionRef?.current?.resetForm(); - } - - async function handleRqstTechRvwClick() { - const payload: DocPrgrsUpdateRequestDto = { - docNo, - docSeComCd, - prgrsSttsComCd: "ER", - }; - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw, - request: payload, - successMessage: "기술검토 의뢰가 완료되었습니다.", - }); - queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); - } - - async function handleElaprTmpltSelect(tmplt: ElaprTmplt) { - const payload = { - elaprDocNo: data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo, - elaprTmpltId: tmplt.elaprTmpltId, - dmndTtl: data.detDto?.bizNm, - dmndCn: data.detDto?.bizCn, - }; - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr, - request: payload, - successMessage: "검토자가 지정되었습니다.", - }); - } - - async function handleRvwStrgClick() { - const payload = buildTechReviewReplyPayload( - data, - checklistFormState, - "KECO", - "TE", - "SV", - getReviewOpinionFormData(), - opnrNm, - ); - - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg, - request: payload, - successMessage: "저장되었습니다.", - }); - techReviewOpinionRef?.current?.resetForm(); - } - - async function handleRvwCmptnClick() { - const { percent } = computeReviewStepChecklistProgress( - data.cklstResultList ?? [], - checklistFormState.results, - "KECO", - ); - - if (percent !== 100) { - alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다."); - return; - } - - const payload = buildTechReviewReplyPayload( - data, - checklistFormState, - "KECO", - "TE", - "CP", - getReviewOpinionFormData(), - opnrNm, - ); - - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn, - request: payload, - successMessage: "검토완료 처리되었습니다.", - }); - techReviewOpinionRef?.current?.resetForm(); - } - - function handleDmndSplmntClick() { - setActiveDialog("dmndSplmnt"); - } - - async function handleDmndSplmntConfirm(splmntDmndCn: string) { - setActiveDialog(null); - const payload = buildSupplementReqPayload(data, splmntDmndCn); - await executeAction({ - action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt, - request: payload, - successMessage: "보완요청이 처리되었습니다.", - }); - } - - return ( - <> -
-
- - {canEdit && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && ( - - )} - {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && ( - - )} -
-
- - setActiveDialog(null)} - onSelect={handleElaprTmpltSelect} - /> - - setActiveDialog(null)} - title="보완요청 내용 입력" - placeholder="보완요청 내용을 입력하세요." - isSubmitting={isProcessing} - onConfirm={(value) => void handleDmndSplmntConfirm(value)} - /> - - setActiveDialog(open ? "elaprApproval" : null)} - elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo} - currentUserUuid={userInfo?.userUnqId ?? ""} - onSuccess={() => { - setActiveDialog(null); - alert("결재가 완료되었습니다."); - queryClient.invalidateQueries({ - queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], - }); - }} - /> - - ); -} - -export { FloatingActionButtons }; - -async function handleSaveApply( - context: ActionHandlerContext, -): Promise { - try { - await fnrsCnsltnMngService.saveApply(context.request); - context.onSuccess?.(); - } catch (error) { - context.onError?.(error as Error); - } -} - -async function handleDel(context: ActionHandlerContext): Promise { - try { - await fnrsCnsltnMngService.remove(context.data.detDto?.fnrsCnsltnDocNo ?? ""); - context.onSuccess?.(); - } catch (error) { - context.onError?.(error as Error); - } -} - -async function handleSplmntCmptn( - context: ActionHandlerContext, -): Promise { - try { - const docNo = context.data.detDto?.fnrsCnsltnDocNo; - if (!docNo) throw new Error("문서번호가 없습니다."); - await fnrsCnsltnMngService.submitSupplement(docNo, context.request); - context.onSuccess?.(); - } catch (error) { - context.onError?.(error as Error); - } -} - -async function handleChangePrgrsSttsComCd( - context: ActionHandlerContext, -): Promise { - try { - await fnrsCnsltnMngService.changePrgrsSttsComCd(context.request); - context.onSuccess?.(); - } catch (error) { - context.onError?.(error as Error); - } -} - -async function handleTechReview( - context: ActionHandlerContext, -): Promise { - try { - await fnrsCnsltnMngService.techReview( - context.data.detDto?.fnrsCnsltnDocNo ?? "", - context.request, - ); - context.onSuccess?.(); - } catch (error) { - context.onError?.(error as Error); - } -} - -async function handleDsgntRvwr( - context: ActionHandlerContext, -): Promise { - try { - await elaprDmndService.save(context.request); - context.onSuccess?.(); - } catch (error) { - context.onError?.(error as Error); - } -} - -async function handleDmndSplmnt( - context: ActionHandlerContext, -): Promise { - try { - const docNo = context.data.detDto?.fnrsCnsltnDocNo; - if (!docNo) throw new Error("문서번호가 없습니다."); - await fnrsCnsltnMngService.submitReqSupplement(docNo, context.request); - context.onSuccess?.(); - } catch (error) { - context.onError?.(error as Error); - } -} - -async function handleAtrzAprv(context: ActionHandlerContext): Promise { - // TODO: 결재 승인 처리 API 호출 - context.onSuccess?.(); -} - -async function handleAtrzRply(context: ActionHandlerContext): Promise { - // TODO: 결재 회신 처리 API 호출 - context.onSuccess?.(); -} - -async function handleGrndsChck(context: ActionHandlerContext): Promise { - // TODO: 현장점검 등록 API 호출 - context.onSuccess?.(); -} - -const buttonActionHandlers: Record = { - strg: handleSaveApply, - aply: handleSaveApply, - del: handleDel, - splmntCmptn: handleSplmntCmptn, - fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd, - envoSbmsn: handleChangePrgrsSttsComCd, - rjct: handleChangePrgrsSttsComCd, - sncnAprv: handleChangePrgrsSttsComCd, - rvwRply: handleTechReview, - rqstTechRvw: handleChangePrgrsSttsComCd, - dsgntRvwr: handleDsgntRvwr, - rvwStrg: handleTechReview, - rvwCmptn: handleTechReview, - dmndSplmnt: handleDmndSplmnt, - atrzAprv: handleAtrzAprv, - atrzRply: handleAtrzRply, - grndsChck: handleGrndsChck, -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -interface ActionHandlerContext { - request: T; - data: FnrsCnsltnMngDetResponseDto; - onSuccess?: () => void; - onError?: (error: Error) => void; -} - -type ActionHandler = (context: ActionHandlerContext) => Promise; +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { + buildElaprPrgrsSttsListFromAplnRows, + type ElaprDmndSaveRequestDto, + elaprDmndService, + hasCurrentUserPendingApprovalRequest, + useElaprDocPrgrsByDocNo, +} from "@/entities/elapr"; +import { + type DocPrgrsUpdateRequestDto, + type FnrsCnsltnApplySaveRequestDto, + fnrsCnsltnMngBaseKey, + type FnrsCnsltnMngDetResponseDto, + fnrsCnsltnMngService, +} from "@/entities/fnrs-cnsltn"; +import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw"; +import { TextInputDialog } from "@/widgets/pbadms-sprt"; +import { Button } from "@common/components"; + +import { + FNRS_CNSLTN_VIEW_BUTTON_ACTION, + type FnrsCnsltnViewButtonAction, + getButtonConfigs, + needsRoleUser01AtrzResolve, +} from "../../lib/button-config"; +import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils"; +import { + buildSavePayload, + buildSupplementReqPayload, + buildSupplementSubmitPayload, + buildTechReviewReplyPayload, +} from "../../lib/save-payload-utils"; +import { type ChecklistFormState } from "../../model/view-types"; +import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section"; + +import { ElaprApprovalDialog } from "./elapr-approval-dialog"; +import { + type ElaprTmpltPickerConfirmPayload, + ElaprTmpltPickerDialog, +} from "./elapr-tmplt-picker-dialog"; + +interface FloatingActionButtonsProps { + data: FnrsCnsltnMngDetResponseDto; + docSeComCd: string; + fnrsCnsltnDocNo: string; + prgrsSttsComCd: string; + dtlInstCd: string | undefined; + checklistFormState: ChecklistFormState; + techReviewOpinionRef?: React.RefObject; + opnrNm?: string; +} + +type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null; + +function FloatingActionButtons({ + data, + docSeComCd, + fnrsCnsltnDocNo, + prgrsSttsComCd, + dtlInstCd, + checklistFormState, + techReviewOpinionRef, + opnrNm, +}: FloatingActionButtonsProps) { + const router = useRouter(); + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const [isProcessing, setIsProcessing] = useState(false); + const [activeDialog, setActiveDialog] = useState(null); + + const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo; + const isRegister = data.detDto?.rgtr === userInfo?.userUnqId; + + const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd); + const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE"; + const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList( + {}, + { enabled: needsAtrzResolve }, + ); + const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } = + useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null); + + const atrzSeComCdList = useMemo( + () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [], + [rvwLoginUserData], + ); + + const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest( + elaprDocPrgrsList, + userInfo?.userUnqId, + ); + + const actionButtons = useMemo(() => { + if (needsAtrzResolve && isRvwLoginUserLoading) return []; + if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return []; + return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, { + hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined, + }); + }, [ + atrzSeComCdList, + dtlInstCd, + hasPendingApprovalRequest, + isElaprDocPrgrsLoading, + isRvwLoginUserLoading, + needsAtrzResolve, + needsTeApprovalCheck, + prgrsSttsComCd, + ]); + + const canEdit = + isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg); + + function handleGoToList() { + router.push("/fnrs-cnsltn"); + } + + function handleEdit() { + router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`); + } + + function shouldShowButton(action: FnrsCnsltnViewButtonAction) { + return actionButtons.some((button) => button === action); + } + + async function executeAction({ + action, + request, + successMessage, + }: { + action: FnrsCnsltnViewButtonAction; + request?: unknown; + successMessage?: string; + }) { + const handler = buttonActionHandlers[action]; + if (!handler) return; + + setIsProcessing(true); + try { + await handler({ + request, + data, + onSuccess: () => { + alert(successMessage ?? "처리가 완료되었습니다."); + queryClient.invalidateQueries({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], + }); + }, + onError: (error) => { + let errorMessage = ""; + if (error instanceof AxiosError) { + errorMessage = error.response?.data?.message ?? ""; + } else { + errorMessage = error.message ?? "처리 중 오류가 발생했습니다."; + } + alert(errorMessage); + }, + }); + } finally { + setIsProcessing(false); + } + } + + async function handleStrgClick() { + const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload( + data, + checklistFormState, + "LOCAL", + "SV", + ); + + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg, + request: payload, + successMessage: "저장되었습니다.", + }); + } + + async function handleAplyClick() { + const { percent } = computeReviewStepChecklistProgress( + data.cklstResultList ?? [], + checklistFormState.results, + "LOCAL", + ); + const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload( + data, + checklistFormState, + "LOCAL", + "ST", + ); + + if (percent !== 100) { + alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다."); + return; + } + + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply, + request: payload, + successMessage: "신청 완료되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + } + + async function handleDelClick() { + if (!(await confirm("삭제하시겠습니까?"))) return; + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del, + successMessage: "삭제되었습니다.", + }); + router.push("/fnrs-cnsltn"); + } + + async function handleSplmntCmptnClick() { + const payload = buildSupplementSubmitPayload(data, checklistFormState); + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn, + request: payload, + successMessage: "보완완료 처리되었습니다.", + }); + } + + async function handleFnrsCnsltnAncmntClick() { + const payload: DocPrgrsUpdateRequestDto = { + docNo, + docSeComCd, + prgrsSttsComCd: "TB", + }; + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt, + request: payload, + successMessage: "고시되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + } + + async function handleEnvoSbmsnClick() { + const payload: DocPrgrsUpdateRequestDto = { + docNo, + docSeComCd, + prgrsSttsComCd: "LE", + }; + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, + request: payload, + successMessage: "제출되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + } + + async function handleRjctClick() { + const payload: DocPrgrsUpdateRequestDto = { + docNo, + docSeComCd, + prgrsSttsComCd: "SV", + }; + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct, + request: payload, + successMessage: "반려되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + } + + async function handleSncnAprvClick() { + const payload: DocPrgrsUpdateRequestDto = { + docNo, + docSeComCd, + prgrsSttsComCd: "TA", + }; + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv, + request: payload, + successMessage: "인가승인되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + } + + function getReviewOpinionFormData() { + return techReviewOpinionRef?.current?.getFormData() ?? null; + } + + async function handleRvwRplyClick() { + const { percent } = computeReviewStepChecklistProgress( + data.cklstResultList ?? [], + checklistFormState.results, + "ENV", + ); + + if (percent !== 100) { + alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다."); + return; + } + + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "ENV", + "EP", + "CP", + getReviewOpinionFormData(), + opnrNm, + ); + + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, + request: payload, + successMessage: "검토결과 회신이 완료되었습니다.", + }); + techReviewOpinionRef?.current?.resetForm(); + } + + async function handleRqstTechRvwClick() { + const payload: DocPrgrsUpdateRequestDto = { + docNo, + docSeComCd, + prgrsSttsComCd: "ER", + }; + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw, + request: payload, + successMessage: "기술검토 의뢰가 완료되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + } + + async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) { + const payload: ElaprDmndSaveRequestDto = { + elaprDocNo: docNo, + dmndTtl: data.detDto?.bizNm ?? "", + dmndCn: data.detDto?.bizCn ?? "", + elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows), + }; + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr, + request: payload, + successMessage: "검토자가 지정되었습니다.", + }); + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo], + }); + } + + async function handleRvwStrgClick() { + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "KECO", + "TE", + "SV", + getReviewOpinionFormData(), + opnrNm, + ); + + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg, + request: payload, + successMessage: "저장되었습니다.", + }); + techReviewOpinionRef?.current?.resetForm(); + } + + async function handleRvwCmptnClick() { + const { percent } = computeReviewStepChecklistProgress( + data.cklstResultList ?? [], + checklistFormState.results, + "KECO", + ); + + if (percent !== 100) { + alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다."); + return; + } + + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "KECO", + "TE", + "CP", + getReviewOpinionFormData(), + opnrNm, + ); + + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn, + request: payload, + successMessage: "검토완료 처리되었습니다.", + }); + techReviewOpinionRef?.current?.resetForm(); + } + + function handleDmndSplmntClick() { + setActiveDialog("dmndSplmnt"); + } + + async function handleDmndSplmntConfirm(splmntDmndCn: string) { + setActiveDialog(null); + const payload = buildSupplementReqPayload(data, splmntDmndCn); + await executeAction({ + action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt, + request: payload, + successMessage: "보완요청이 처리되었습니다.", + }); + } + + return ( + <> +
+
+ + {canEdit && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && ( + + )} + {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && ( + + )} +
+
+ + setActiveDialog(null)} + onConfirm={handleElaprTmpltConfirm} + /> + + setActiveDialog(null)} + title="보완요청 내용 입력" + placeholder="보완요청 내용을 입력하세요." + isSubmitting={isProcessing} + onConfirm={handleDmndSplmntConfirm} + /> + + setActiveDialog(open ? "elaprApproval" : null)} + elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo} + currentUserUuid={userInfo?.userUnqId ?? ""} + onSuccess={() => { + setActiveDialog(null); + alert("결재가 완료되었습니다."); + queryClient.invalidateQueries({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], + }); + }} + /> + + ); +} + +export { FloatingActionButtons }; + +async function handleSaveApply( + context: ActionHandlerContext, +): Promise { + try { + await fnrsCnsltnMngService.saveApply(context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleDel(context: ActionHandlerContext): Promise { + try { + await fnrsCnsltnMngService.remove(context.data.detDto?.fnrsCnsltnDocNo ?? ""); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleSplmntCmptn( + context: ActionHandlerContext, +): Promise { + try { + const docNo = context.data.detDto?.fnrsCnsltnDocNo; + if (!docNo) throw new Error("문서번호가 없습니다."); + await fnrsCnsltnMngService.submitSupplement(docNo, context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleChangePrgrsSttsComCd( + context: ActionHandlerContext, +): Promise { + try { + await fnrsCnsltnMngService.changePrgrsSttsComCd(context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleTechReview( + context: ActionHandlerContext, +): Promise { + try { + await fnrsCnsltnMngService.techReview( + context.data.detDto?.fnrsCnsltnDocNo ?? "", + context.request, + ); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleDsgntRvwr( + context: ActionHandlerContext, +): Promise { + try { + await elaprDmndService.save(context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleDmndSplmnt( + context: ActionHandlerContext, +): Promise { + try { + const docNo = context.data.detDto?.fnrsCnsltnDocNo; + if (!docNo) throw new Error("문서번호가 없습니다."); + await fnrsCnsltnMngService.submitReqSupplement(docNo, context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleAtrzAprv(context: ActionHandlerContext): Promise { + // TODO: 결재 승인 처리 API 호출 + context.onSuccess?.(); +} + +async function handleAtrzRply(context: ActionHandlerContext): Promise { + // TODO: 결재 회신 처리 API 호출 + context.onSuccess?.(); +} + +async function handleGrndsChck(context: ActionHandlerContext): Promise { + // TODO: 현장점검 등록 API 호출 + context.onSuccess?.(); +} + +const buttonActionHandlers: Record = { + strg: handleSaveApply, + aply: handleSaveApply, + del: handleDel, + splmntCmptn: handleSplmntCmptn, + fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd, + envoSbmsn: handleChangePrgrsSttsComCd, + rjct: handleChangePrgrsSttsComCd, + sncnAprv: handleChangePrgrsSttsComCd, + rvwRply: handleTechReview, + rqstTechRvw: handleChangePrgrsSttsComCd, + dsgntRvwr: handleDsgntRvwr, + rvwStrg: handleTechReview, + rvwCmptn: handleTechReview, + dmndSplmnt: handleDmndSplmnt, + atrzAprv: handleAtrzAprv, + atrzRply: handleAtrzRply, + grndsChck: handleGrndsChck, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface ActionHandlerContext { + request: T; + data: FnrsCnsltnMngDetResponseDto; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +type ActionHandler = (context: ActionHandlerContext) => Promise; 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 new file mode 100644 index 000000000..4d25cb4eb --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Equal } from "lucide-react"; + +import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code"; +import type { ElaprAplnRow } from "@/entities/elapr"; +import { Button, TableCell, TableRow } from "@common/components"; + +interface SortableAplnPickerRowProps { + row: ElaprAplnRow; + displaySeqNo: number; + isEditing: boolean; + isLocked: boolean; + dragDisabled: boolean; + onStartEdit: () => void; + onSave: () => void; + onCancel: () => void; + onDelete: () => void; + onChange: (patch: Partial) => void; + onPickAprvr: () => void; +} + +function SortableAplnPickerRow({ + row, + displaySeqNo, + isEditing, + isLocked, + dragDisabled, + onStartEdit, + onSave, + onCancel, + onDelete, + onChange, + onPickAprvr, +}: SortableAplnPickerRowProps) { + 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 rowDisabled = isLocked && !isEditing; + const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd); + + return ( + + + + + {row.isNew ? "-" : (row.aplnId ?? "-")} + {displaySeqNo} + + {isEditing ? ( + + onChange({ + atrzSeComCd: v, + atrzSeComNm: findCodeLabel(atrzSeCodes, v), + }) + } + placeholder="결재구분" + /> + ) : ( + atrzSeLabel || "-" + )} + + +
+ {row.aprvrNm || row.aprvrUuid || "-"} + {isEditing && ( + + )} +
+
+ +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ ); +} + +export { SortableAplnPickerRow }; diff --git a/apps/as/src/pages/instl-sncn-old/index.ts b/apps/as/src/pages/instl-sncn-old/index.ts deleted file mode 100644 index 570133ba4..000000000 --- a/apps/as/src/pages/instl-sncn-old/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { CnfmListPage } from "./ui/cnfm-list-page"; -export { CnfmViewPage } from "./ui/cnfm-view-page"; -export { CnfmRegistPage } from "./ui/cnfm-regist-page"; -export { CnfmModifyPage } from "./ui/cnfm-modify-page"; diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx deleted file mode 100644 index 2b8c56174..000000000 --- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx +++ /dev/null @@ -1,441 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; - -import { useQuery } from "@tanstack/react-query"; - -import { dayjs } from "@/shared/lib"; -import { useCodeList } from "@/entities/code"; -import { - INSTL_SNCN_REGISTER_SELECTION_KEY, - type InstlSncnMngSearchParams, - instlSncnService, -} from "@/entities/instl-sncn"; - -import "./cnfm-page.css"; - -const TASK_OPTIONS = [ - { value: "", label: "전체" }, - { value: "SF", label: "하수처리시설" }, - { value: "SP", label: "하수관로" }, - { value: "RE", label: "재이용수" }, -]; - -const SIDO_OPTIONS = [ - { value: "", label: "전체" }, - { value: "11", label: "서울특별시" }, - { value: "26", label: "부산광역시" }, - { value: "27", label: "대구광역시" }, - { value: "28", label: "인천광역시" }, - { value: "29", label: "광주광역시" }, - { value: "30", label: "대전광역시" }, - { value: "31", label: "울산광역시" }, - { value: "36", label: "세종특별자치시" }, - { value: "41", label: "경기도" }, - { value: "43", label: "충청북도" }, - { value: "44", label: "충청남도" }, - { value: "45", label: "전라북도" }, - { value: "46", label: "전라남도" }, - { value: "47", label: "경상북도" }, - { value: "48", label: "경상남도" }, - { value: "50", label: "제주특별자치도" }, -]; - -type StepCode = "SV" | "ST" | "ER" | "TE" | "TR" | "TP" | "TA"; - -const STEPS: { code: StepCode; label: string }[] = [ - { code: "SV", label: "저장" }, - { code: "ST", label: "신청완료" }, - { code: "ER", label: "환경청 검토" }, - { code: "TE", label: "기술검토" }, - { code: "TR", label: "검토결과 회신" }, - { code: "TP", label: "기술평가" }, - { code: "TA", label: "최종승인" }, -]; - -const PAGE_GROUP_SIZE = 5; - -function CnfmListPage() { - const router = useRouter(); - const [bizNm, setBizNm] = useState(""); - const [taskSeComCd, setTaskSeComCd] = useState(""); - const [sidoComCd, setSidoComCd] = useState(""); - const [sggComCd, setSggComCd] = useState(""); - const [search, setSearch] = useState({ - bizNm: "", - taskSeComCd: "", - sidoComCd: "", - sggComCd: "", - }); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - const [taskDialogOpen, setTaskDialogOpen] = useState(false); - const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState([]); - - const { data: fnrsCodeList = [] } = useCodeList("INSTL"); - - useEffect(() => { - if (!taskDialogOpen) return; - if (selectedTaskSeComCd.length) return; - if (fnrsCodeList.length === 0) return; - // setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]); - }, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]); - - const query = useQuery({ - queryKey: ["instl-sncn-list", search, pageIndex, pageSize], - queryFn: () => - instlSncnService.getPage({ - ...search, - page: pageIndex, - size: pageSize, - sort: "regDt,desc", - }), - }); - - const totalPages = query.data?.totalPages ?? 0; - - const pageGroupStart = Math.floor(pageIndex / PAGE_GROUP_SIZE) * PAGE_GROUP_SIZE; - const pageNumbers = useMemo(() => { - const end = Math.min(pageGroupStart + PAGE_GROUP_SIZE, totalPages); - return Array.from({ length: end - pageGroupStart }, (_, i) => pageGroupStart + i); - }, [pageGroupStart, totalPages]); - - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - setPageIndex(0); - setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd }); - }; - - const handleOpenTaskDialog = () => { - //setSelectedTaskSeComCd((prev) => (prev && prev.length ? prev : [fnrsCodeList[0]?.lwpoCd || ""])); - setTaskDialogOpen(true); - }; - - const handleConfirmTaskDialog = () => { - if (!selectedTaskSeComCd || selectedTaskSeComCd.length === 0) { - alert("하나 이상의 업무구분을 선택하세요."); - return; - } - - // prepare arrays of selected codes and names - const selectedCodes = selectedTaskSeComCd; - const selectedNames = selectedTaskSeComCd.map( - (code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "", - ); - sessionStorage.setItem( - INSTL_SNCN_REGISTER_SELECTION_KEY, - JSON.stringify({ - taskSeComCdList: selectedCodes, - taskSeComCdNmList: selectedNames, - }), - ); - setTaskDialogOpen(false); - router.push("/instl-sncn/register"); - }; - - return ( -
- {/* 검색 영역 */} -
-
-
- - -
- -
- - -
- -
- - setBizNm(e.target.value)} - placeholder="사업명을 입력하세요" - /> -
- -
- - -
- - -
-
- - {/* 목록 영역 */} -
-
-

설치인가 목록

-
- - -
-
- -
- - - - - - - - - - - - - - - {query.isLoading && ( - - - - )} - {!query.isLoading && (query.data?.content?.length ?? 0) === 0 && ( - - - - )} - {(query.data?.content ?? []).map((row) => { - const currentStepIndex = STEPS.findIndex((s) => s.code === row.prgrsSttsComCd); - return ( - router.push(`/instl-sncn/${row.instlSncnDocNo}`)} - > - - - - - - - - - - ); - })} - -
시도시군구사업명구분담당자해당기관진행상황신청일
- 조회 중입니다... -
- 조회 결과가 없습니다. -
{row.sido || "-"}{row.gugun || "-"}{row.bizNm || "-"}{row.taskSeCdNm || row.taskSeComCd || "-"}{row.aplcntNm || "-"}{row.envoSeCdNm || "-"} -
-
- {STEPS.slice(0, 4).map((step, index) => { - const state = - index < currentStepIndex - ? "completed" - : index === currentStepIndex - ? "current" - : "pending"; - return ( - - - {step.label} - - ); - })} -
-
- {STEPS.slice(4).map((step, i) => { - const index = i + 4; - const state = - index < currentStepIndex - ? "completed" - : index === currentStepIndex - ? "current" - : "pending"; - return ( - - - {step.label} - - ); - })} -
-
-
{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}
-
- - {/* 페이지네이션 */} -
- - - - {pageNumbers.map((pageNo) => ( - - ))} - - - -
-
- - {taskDialogOpen && ( -
-
-
-

업무구분 선택

- -
- -
-
- {fnrsCodeList.map((item) => ( - - ))} -
-
- -
- - -
-
-
- )} -
- ); -} - -export { CnfmListPage }; diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx deleted file mode 100644 index 4be0db4fd..000000000 --- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { instlSncnService } from "@/entities/instl-sncn"; - -import { CnfmRegistPage } from "./cnfm-regist-page"; - -import "./cnfm-page.css"; - -function CnfmModifyPage({ id }: { id: string }) { - const query = useQuery({ - // share cache with view page - queryKey: ["instl-sncn-detail", id], - queryFn: () => instlSncnService.getById(id), - }); - - if (query.isLoading) { - return
수정 정보를 불러오는 중입니다...
; - } - - if (!query.data) { - return
수정 대상을 찾을 수 없습니다.
; - } - - const data = query.data; - - if (!data) return
수정 대상을 찾을 수 없습니다.
; - - return ( -
- -
- ); -} - -export { CnfmModifyPage }; diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css b/apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css deleted file mode 100644 index 49d7a6bce..000000000 --- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css +++ /dev/null @@ -1,490 +0,0 @@ -/* ============================ - 페이지 레이아웃 -============================= */ -.cnfm-page-wrap { - padding: 2rem 2.4rem; -} - -/* ============================ - 카드 -============================= */ -.cnfm-card { - border: 1px solid #e2e8f0; - border-radius: 10px; - background: #ffffff; - padding: 1.6rem 2rem; -} - -.cnfm-card.mt-4 { - margin-top: 1.6rem; -} - -/* ============================ - 검색 바 -============================= */ -.cnfm-search-bar { - display: flex; - align-items: center; - gap: 1.2rem; - flex-wrap: wrap; -} - -.cnfm-search-field { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.cnfm-search-field--grow { - flex: 1; - min-width: 18rem; -} - -.cnfm-search-label { - font-size: 1.3rem; - font-weight: 600; - color: #374151; - white-space: nowrap; -} - -.cnfm-input { - height: 3.6rem; - border: 1px solid #d1d5db; - border-radius: 6px; - padding: 0 1rem; - font-size: 1.3rem; - color: #111827; - width: 100%; - outline: none; - background: #fff; -} - -.cnfm-input:focus { - border-color: #3b82f6; - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); -} - -.cnfm-select { - height: 3.6rem; - border: 1px solid #d1d5db; - border-radius: 6px; - padding: 0 0.8rem; - font-size: 1.3rem; - color: #111827; - background: #fff; - min-width: 11rem; - cursor: pointer; - outline: none; -} - -.cnfm-select--wide { - min-width: 16rem; -} - -.cnfm-select:focus { - border-color: #3b82f6; -} - -.cnfm-btn-search { - height: 3.6rem; - padding: 0 2rem; - background: #3b82f6; - color: #fff; - font-size: 1.3rem; - font-weight: 600; - border: none; - border-radius: 6px; - cursor: pointer; - white-space: nowrap; -} - -.cnfm-btn-search:hover { - background: #2563eb; -} - -/* ============================ - 목록 헤더 -============================= */ -.cnfm-list-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1.2rem; -} - -.cnfm-list-title { - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; -} - -.cnfm-list-actions { - display: flex; - align-items: center; - gap: 0.8rem; -} - -.cnfm-btn-register { - height: 3.4rem; - padding: 0 1.6rem; - background: #22c55e; - color: #fff; - font-size: 1.3rem; - font-weight: 600; - border: none; - border-radius: 6px; - cursor: pointer; - white-space: nowrap; -} - -.cnfm-btn-register:hover { - background: #16a34a; -} - -/* ============================ - 테이블 -============================= */ -.cnfm-table-wrap { - overflow-x: auto; -} - -.cnfm-table { - width: 100%; - border-collapse: collapse; - font-size: 1.3rem; -} - -.cnfm-table th, -.cnfm-table td { - border-bottom: 1px solid #e5e7eb; - padding: 1.2rem 0.8rem; - text-align: center; - vertical-align: middle; -} - -.cnfm-table thead th { - background: #f8fafc; - color: #374151; - font-weight: 600; - font-size: 1.25rem; - border-bottom: 2px solid #e2e8f0; -} - -.cnfm-table tbody td { - color: #111827; -} - -.cnfm-td-empty { - padding: 3rem 0; - color: #9ca3af; - font-size: 1.3rem; -} - -.cnfm-row-clickable { - cursor: pointer; -} - -.cnfm-row-clickable:hover { - background: #f8fafc; -} - -/* ============================ - 진행상황 스텝 배지 -============================= */ -.cnfm-step-wrap { - display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: flex-start; -} - -.cnfm-step-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: nowrap; -} - -.cnfm-step-badge { - display: inline-flex; - align-items: center; - gap: 0.4rem; - border-radius: 9999px; - padding: 0.3rem 0.9rem; - font-size: 1.15rem; - font-weight: 500; - white-space: nowrap; - border: 1px solid transparent; -} - -.cnfm-step-badge--completed { - background: #dbeafe; - border-color: #93c5fd; - color: #1d4ed8; -} - -.cnfm-step-badge--current { - background: #fed7aa; - border-color: #fb923c; - color: #c2410c; - font-weight: 700; -} - -.cnfm-step-badge--pending { - background: #f3f4f6; - border-color: #d1d5db; - color: #9ca3af; -} - -.cnfm-step-dot { - display: inline-block; - width: 0.7rem; - height: 0.7rem; - border-radius: 50%; - flex-shrink: 0; -} - -.cnfm-step-dot--completed { - background: #3b82f6; -} - -.cnfm-step-dot--current { - background: #f97316; -} - -.cnfm-step-dot--pending { - background: #d1d5db; -} - -/* ============================ - 페이지네이션 -============================= */ -.cnfm-pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 0.4rem; - margin-top: 1.6rem; - padding-bottom: 0.4rem; -} - -.cnfm-page-btn { - min-width: 3.2rem; - height: 3.2rem; - border: 1px solid #e2e8f0; - border-radius: 6px; - background: #fff; - font-size: 1.3rem; - color: #374151; - cursor: pointer; - transition: all 0.15s; - padding: 0 0.6rem; -} - -.cnfm-page-btn:hover:not(:disabled) { - border-color: #3b82f6; - color: #3b82f6; -} - -.cnfm-page-btn.active { - border-color: #3b82f6; - background: #3b82f6; - color: #fff; - font-weight: 700; -} - -.cnfm-page-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* ============================ - 폼 테이블 (상세/등록 페이지용) -============================= */ -.cnfm-form-table { - width: 100%; - border-collapse: collapse; - font-size: 1.3rem; -} - -.cnfm-form-table th, -.cnfm-form-table td { - border: 1px solid #e5e7eb; - padding: 0.9rem 1rem; -} - -.cnfm-form-table th { - width: 16rem; - background: #f9fafb; - text-align: left; - font-weight: 700; -} - -.cnfm-form-table td { - background: #fff; -} - -.cnfm-action-row { - display: flex; - justify-content: center; - gap: 1rem; - margin-top: 1.8rem; -} - -/* ============================ - 기존 호환 클래스 유지 -============================= */ -.cnfm-section-title { - font-size: 1.8rem; - font-weight: 700; - color: #1f2937; -} - -@media (max-width: 1100px) { - .cnfm-search-bar { - flex-direction: column; - align-items: stretch; - } - .cnfm-search-field { - flex-direction: column; - align-items: flex-start; - } - .cnfm-search-field--grow { - width: 100%; - } -} - - -.cnfm-section-title { - font-size: 1.8rem; - font-weight: 700; - color: #1f2937; -} - -.cnfm-search-grid { - display: grid; - grid-template-columns: 8rem 1fr 6rem 16rem auto auto; - gap: 1.2rem; - align-items: center; -} - -.cnfm-table-wrap { - overflow-x: auto; -} - -.cnfm-table { - width: 100%; - border-collapse: collapse; - font-size: 1.3rem; -} - -.cnfm-table th, -.cnfm-table td { - border: 1px solid #e5e7eb; - padding: 1rem 0.8rem; - text-align: center; - vertical-align: middle; -} - -.cnfm-table thead th { - background: #f3f4f6; - color: #111827; - font-weight: 700; -} - -.cnfm-row-clickable { - cursor: pointer; -} - -.cnfm-row-clickable:hover { - background: #f9fafb; -} - -.cnfm-step { - display: inline-flex; - align-items: center; - gap: 0.4rem; - flex-wrap: wrap; - justify-content: center; -} - -.cnfm-step-badge { - border-radius: 9999px; - border: 1px solid #d1d5db; - background: #f9fafb; - color: #6b7280; - font-size: 1.1rem; - padding: 0.25rem 0.65rem; -} - -.cnfm-step-badge.completed { - border-color: #93c5fd; - background: #dbeafe; - color: #1d4ed8; -} - -.cnfm-step-badge.current { - border-color: #16a34a; - background: #dcfce7; - color: #166534; - font-weight: 700; -} - -.cnfm-pagination { - display: flex; - justify-content: center; - gap: 0.6rem; - margin-top: 1.2rem; -} - -.cnfm-pagination button { - min-width: 3.2rem; - height: 3.2rem; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #fff; - font-size: 1.3rem; -} - -.cnfm-pagination button.active { - border-color: #2563eb; - background: #2563eb; - color: #fff; - font-weight: 700; -} - -.cnfm-form-table { - width: 100%; - border-collapse: collapse; - font-size: 1.3rem; -} - -.cnfm-form-table th, -.cnfm-form-table td { - border: 1px solid #e5e7eb; - padding: 0.9rem 1rem; -} - -.cnfm-form-table th { - width: 16rem; - background: #f9fafb; - text-align: left; - font-weight: 700; -} - -.cnfm-form-table td { - background: #fff; -} - -.cnfm-action-row { - display: flex; - justify-content: center; - gap: 1rem; - margin-top: 1.8rem; -} - -@media (max-width: 1100px) { - .cnfm-search-grid { - grid-template-columns: 1fr; - } -} diff --git a/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx deleted file mode 100644 index 7b545ec06..000000000 --- a/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx +++ /dev/null @@ -1,1945 +0,0 @@ -"use client"; -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { useEffect, useMemo, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import axios from "axios"; -import { type HttpResponse, Upload } from "tus-js-client"; - -import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config"; -import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib"; -import { formatFileSize } from "@/shared/lib/file-utils"; -import type { AcntgSeMngTreeDto } from "@/entities/acntg"; -import { acntgSeMngService } from "@/entities/acntg"; -import { attachService } from "@/entities/attach"; -import type { CklstTreeNode } from "@/entities/cklst"; -import { cklstMngService } from "@/entities/cklst"; -import type { CnstpSeMngTreeDto } from "@/entities/cnstp"; -import { cnstpSeMngService } from "@/entities/cnstp"; -import { esntlAtchFileMngService, type EsntlAtchFileMngTreeResponseDto } from "@/entities/esntl"; -import { - INSTL_SNCN_REGISTER_SELECTION_KEY, - type InstlSncnApplySaveRequestDto, - type InstlSncnBizCstSaveRequestDto, - type InstlSncnBzScaleSaveDto, - type InstlSncnMngResponseDto, - InstlSncnMngSaveRequestDto, - instlSncnService, -} from "@/entities/instl-sncn"; -import { EtcAttachments, NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm"; -import { Button, Input } from "@common/components"; - -import "./cnfm-page.css"; - -/* ------------------------------------------------------------------ */ -/* 타입 */ -/* ------------------------------------------------------------------ */ -type BzScaleRow = { - bscPl: string; // 예산반영(A) - budgetReflct: string; // 표준사업비(B) - thtiDsgn: string; // 금회설계시(C) -}; - -type BzAmtRow = { - bscPl: string; - budgetReflct: string; - thtiDsgn: string; -}; - -type RequiredAttachKey = string; - -type RequiredAttachItem = { - key: RequiredAttachKey; - esntlAtchFileNm: string; - esntlAtchYn: "Y" | "N"; - esntlAtchFileRmrk: string; - atflCd: string; - atflId: string; - fileName: string; - uploading: boolean; -}; - -const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" }); -const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" }); - -const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0"); -const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString()); -const DEFAULT_RVW_STP_COM_CD = "LOCAL"; //검토단계공통코드: RVWSTP ( 상세 코드 : LOCAL.지자체,ENV.환경청,KECO.환경공단) -const DEFAULT_SNCN_SE_COM_CD = "CNFM"; -type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; -const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"]; -const ROLE_TO_REVIEW_STEP: Record = { - ROLE_USER05: "LOCAL", - ROLE_USER04: "LOCAL", - ROLE_USER03: "ENV", - ROLE_USER02: "KECO", - ROLE_USER01: "KECO", -}; -const REVIEW_STEP_ROLE_RANK: Record = { - LOCAL: 1, - ENV: 2, - KECO: 3, -}; -const INSTL_PROGRESS_ORDER = [ - "SV", - "ST", - "LE", - "ER", - "EE", - "TE", - "TS", - "TR", - "TP", - "EP", - "TA", - "TB", -]; - -const getProgressStepCount = (prgrsSttsComCd?: string): number => { - const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd ?? "ST"); - if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1; - if (idx <= INSTL_PROGRESS_ORDER.indexOf("ER")) return 2; - return 3; -}; - -const getRoleStepCount = (dtlInstCd?: string): number => { - const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; - return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; -}; - -// 업무구분 코드 -> 표시명 매핑 (체크리스트 그룹 표시용) -const TASK_CODE_LABELS: Record = { - RE: "재이용수", - SP: "하수관로", - SF: "하수처리시설", -}; - -const normalizeChecklistResultRows = (payload: any): any[] => { - // expect flat `cklstResultList` only - const raw = payload?.cklstResultList ?? []; - return Array.isArray(raw) ? raw : []; -}; - -/* ------------------------------------------------------------------ */ -/* 메인 컴포넌트 */ -/* ------------------------------------------------------------------ */ -/** - * initialData 매핑 안내 - * - initialData 데이터는 cnfm-modify-page에서 서버로부터 받아온 상세 데이터를 그대로 전달받거나, cnfm-regist-page에서 수정 모드로 진입할 때 전달받을 수 있습니다. - * - 이 컴포넌트는 수정 모드로 호출될 때 `initialData`를 받습니다. - * - 서버 응답은 두 가지 형태 중 하나일 수 있습니다: - * 1) 상세 객체 그대로: { ...detFields } - * 2) 래퍼 형태: { detDto: { ...detFields }, bizSclList: [...], bizCstList: [...], cklstResultList: [...], esntlFileList: [...], etcFileList: [...] } - * - * - 주요 detFields (det 또는 detDto 내부): - * - bizNm, bizBgngYr, bizEndYr, bizCn - * - sncnSeComCd or envoSeComCd or envoSeCd (설치구분) - * - dsgnrNm, dsgnrOgdpNm (소속), dsgnrTelno, dsgnrEmlAddr - * - privacyAgree ("Y" | "N") - * - aplcntNm, aplcntMblTelno, aplcntOgdpNm, aplcntEmlAddr - * - * - 주요 배열 (위치: 최상위 내부) - * - bizSclList: [{ cnstpSeCd, bscPl|bscPlanQty, budgetReflct|bgtRfltQty, thtiDsgn|tstmDesignQty }, ...] - * - bizCstList: [{ acntgSeCd, bscPlanAmt|bscPl, bgtRfltAmt|budgetReflct, tstmDesignAmt|thtiDsgn }, ...] - * - cklstResultList: [{ cklstSn, cklstArtclSn, cklstRvwSn, rvwStpComCd, rvwRsltAnmlyYn: 'Y'|'N'|null, ... }, ...] - * - esntlFileList: [{ esntlAtchFileId, esntlAtchFileNm, esntlAtchYn, atflCd, atflId, orgnlFileNm, ... }, ...] - * - etcFileList: [{ atflId, orgnlFileNm, atflFlieSz, upldUserNm, atflCd, ... }, ...] - * - * - 주의사항: - * - 필드명이 서버의 버전이나 API 경로에 따라 달라질 수 있어(예: bscPl vs bscPlanQty) 코드에서 양쪽을 모두 확인합니다. - * - 일부 배열/속성은 최상위에 있거나 detDto 내부에 있을 수 있으므로 둘 다 우선순위로 체크합니다. - */ -function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { - const router = useRouter(); - const userInfo = useUserStore((state) => state.userInfo); - // userInfo?.dtlInstCd 테스트 코드 ENV.ROLE_USER03 , KECO 권한 테스트용 (실제 권한에 맞게 변경 필요) - /* - const userInfo = { - ...userInfo2, - dtlInstCd: "ROLE_USER03", //ROLE_USER03, ROLE_USER02, ROLE_USER01 KECO 권한 테스트용 (실제 권한에 맞게 변경 필요) - }; - */ - const alert = useAlertStore((state) => state.setMessage); - const qc = useQueryClient(); - const currentDet = useMemo(() => initialData?.detDto ?? initialData, [initialData]); - const isModifyMode = Boolean(currentDet?.instlSncnDocNo); - const currentPrgrsSttsComCd = (currentDet?.prgrsSttsComCd as string | undefined) ?? "ST"; - const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd); - const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd); - const visibleStepCount = isModifyMode - ? Math.min(progressBasedMaxStepCount, roleBasedMaxStepCount) - : 1; - const visibleReviewStepCodes = useMemo( - () => REVIEW_STEP_CODES.slice(0, visibleStepCount), - [visibleStepCount], - ); - const editableReviewStep = useMemo(() => { - if (!isModifyMode) return "LOCAL" as ReviewStepCode; - const step = userInfo?.dtlInstCd ? ROLE_TO_REVIEW_STEP[userInfo.dtlInstCd] : undefined; - if (!step) return null; - return visibleReviewStepCodes.includes(step) ? step : null; - }, [isModifyMode, userInfo?.dtlInstCd, visibleReviewStepCodes]); - const [selectedReviewStep, setSelectedReviewStep] = useState("LOCAL"); - - useEffect(() => { - if (visibleReviewStepCodes.length === 0) return; - // Only set default/initial selection when visible steps or editable step change. - // Do NOT include `selectedReviewStep` in deps to avoid overwriting user clicks. - setSelectedReviewStep((current) => { - // keep current if still visible - if (current && visibleReviewStepCodes.includes(current)) return current; - // prefer editable step if available - if (editableReviewStep) return editableReviewStep; - // otherwise pick the last visible step - return visibleReviewStepCodes[visibleReviewStepCodes.length - 1]; - }); - }, [editableReviewStep, visibleReviewStepCodes]); - - const docId = useMemo(() => { - return currentDet?.instlSncnDocNo ?? ""; - }, [currentDet]); - - const [docSeComCd] = useState("INSTL"); - const [taskSeComCdNm, setTaskSeComCdNm] = useState( - () => (currentDet?.taskSeComCdNm as string) ?? "", - ); - const [taskSeComCd, setTaskSeComCd] = useState( - () => (currentDet?.taskSeComCd as string) ?? "RE", - ); - - useEffect(() => { - // do not override server-provided initialData (modify mode) - if (currentDet) return; - - const raw = sessionStorage.getItem(INSTL_SNCN_REGISTER_SELECTION_KEY); - if (!raw) return; - - try { - const parsed = JSON.parse(raw) as { - taskSeComCdList?: string[]; - taskSeComCdNmList?: string[]; - }; - - setTaskSeComCd(parsed.taskSeComCdList?.join(",") || "RE"); - setTaskSeComCdNm(parsed.taskSeComCdNmList?.join(",") || "재이용수"); - } catch { - // noop - } finally { - sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY); - } - }, [currentDet]); - /* ── 기본 정보 ── */ - const [bzNm, setBzNm] = useState(""); - const [bzBgngYr, setBzBgngYr] = useState(""); - const [bzEndYr, setBzEndYr] = useState(""); - const [bzCn, setBzCn] = useState(""); - const [sncnSeComCd, setSncnSeComCd] = useState("CNFM"); - - /* ── 설계자 정보 ── */ - const [dsgnrNm, setDsgnrNm] = useState(""); - const [dsgnrPsitn, setDsgnrPsitn] = useState(""); - const [dsgnrTelno, setDsgnrTelno] = useState(""); - const [dsgnrEmlAddr, setDsgnrEmlAddr] = useState(""); - - /* ── 신청자 정보 (수정 모드 매핑용) ── */ - const [aplcntNm, setAplcntNm] = useState(""); - const [aplcntMblTelno, setAplcntMblTelno] = useState(""); - const [aplcntOgdpNm, setAplcntOgdpNm] = useState(""); - const [aplcntEmlAddr, setAplcntEmlAddr] = useState(""); - - /* ── 개인정보 동의 ── */ - const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N"); - - const { data: requiredAttachmentDefs = [] } = useQuery({ - queryKey: ["esntlAtchFileTree", docSeComCd], - queryFn: () => esntlAtchFileMngService.findTree({ docSeComCd }), - enabled: Boolean(docSeComCd), - staleTime: Infinity, - }); - - // selected task code for header filtering (null = none selected) - const [selectedTaskCode, setSelectedTaskCode] = useState(null); - - const [requiredFiles, setRequiredFiles] = useState>( - {}, - ); - const requiredFileInputRefs = useRef>({}); - - useEffect(() => { - if (requiredAttachmentDefs.length === 0) return; - - setRequiredFiles((prev) => { - const next: Record = {}; - for (const item of requiredAttachmentDefs) { - const key = item.esntlAtchFileId; - const prevItem = prev[key]; - next[key] = { - key, - esntlAtchFileNm: item.esntlAtchFileNm, - esntlAtchYn: item.esntlAtchYn, - esntlAtchFileRmrk: item.esntlAtchFileRmrk, - atflCd: prevItem?.atflCd ?? "", - atflId: prevItem?.atflId ?? "", - fileName: prevItem?.fileName ?? "", - uploading: prevItem?.uploading ?? false, - }; - } - return next; - }); - }, [requiredAttachmentDefs]); - - const uploadRequiredFile = (file: File, key: RequiredAttachKey) => { - setRequiredFiles((prev) => ({ - ...prev, - [key]: { ...prev[key], uploading: true }, - })); - - setCookie("atfl-cd-key", ""); - - const upload = new Upload(file, { - endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, - chunkSize: 10 * 1024 * 1024, - headers: { - [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", - [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", - }, - metadata: { - filename: file.name, - filetype: file.type, - }, - onError(error: Error) { - console.error("필수 첨부파일 업로드 실패", error); - setRequiredFiles((prev) => ({ - ...prev, - [key]: { ...prev[key], uploading: false }, - })); - alert("필수 첨부파일 업로드 중 오류가 발생했습니다."); - }, - onAfterResponse(_req: unknown, res: HttpResponse) { - const fileInfo = res.getHeader("X-File-Info"); - if (typeof fileInfo !== "string") return; - - const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string }; - const atflCd = parsed.atflCd ?? ""; - const atflId = parsed.atflId ?? ""; - if (atflCd) setCookie("atfl-cd-key", atflCd); - - setRequiredFiles((prev) => ({ - ...prev, - [key]: { - ...prev[key], - atflCd, - atflId, - fileName: file.name, - uploading: false, - }, - })); - }, - onSuccess() { - setRequiredFiles((prev) => ({ - ...prev, - [key]: { ...prev[key], uploading: false }, - })); - }, - }); - - upload.start(); - }; - - /* ── 기타 첨부파일 ── */ - type EtcFile = { - id: string; - name: string; - ext: string; - size: string; - uploader: string; - atflCd: string; - atflId: string; - }; - const [etcFiles, setEtcFiles] = useState([]); - const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); - const [etcFileGroupId, setEtcFileGroupId] = useState(""); - const [etcUploading, setEtcUploading] = useState(false); - const etcFileGroupIdRef = useRef(""); - const initialMappedRef = useRef(false); - - // 초기 데이터가 주어지면 폼 상태로 매핑 (수정 모드에서 사용) - useEffect(() => { - if (!initialData) return; - if (initialMappedRef.current) return; - - try { - const det = (currentDet ?? {}) as any; - - const bizSclList = initialData.bizSclList ?? []; - const bizCstList = initialData.bizCstList ?? []; - //const cklstResultList = initialData.cklstResultList ?? []; - const esntlFileList = initialData.esntlFileList ?? []; - const etcFileList = initialData.etcFileList ?? []; - - // 기본 정보 및 설계자 정보 - setBzNm(det.bizNm ?? ""); - setBzBgngYr(det.bizBgngYr ?? ""); - setBzEndYr(det.bizEndYr ?? ""); - setBzCn(det.bizCn ?? ""); - setSncnSeComCd(det.sncnSeComCd ?? det.envoSeComCd ?? det.envoSeCd ?? DEFAULT_SNCN_SE_COM_CD); - - // 설계자 - setDsgnrNm(det.dsgnrNm ?? ""); - setDsgnrPsitn(det.dsgnrOgdpNm ?? ""); - setDsgnrTelno(det.dsgnrTelno ?? ""); - setDsgnrEmlAddr(det.dsgnrEmlAddr ?? ""); - - // 개인정보 동의 - if (det.privacyAgree) setPrivacyAgree(det.privacyAgree === "Y" ? "Y" : "N"); - - // 신청자 - setAplcntNm(det.aplcntNm ?? ""); - setAplcntMblTelno(det.aplcntMblTelno ?? ""); - setAplcntOgdpNm(det.aplcntOgdpNm ?? ""); - setAplcntEmlAddr(det.aplcntEmlAddr ?? ""); - // 수정모드 기타첨부파일 - if (Array.isArray(etcFileList)) { - setEtcFiles( - etcFileList - .filter((f: any) => f && typeof f === "object") - .map((f: any) => { - const fileName = f.orgnlFileNm ?? f.physFileNm ?? ""; - const ext = - fileName && typeof fileName === "string" && fileName.includes(".") - ? (fileName.split(".").pop() ?? "") - : ""; - return { - id: f.atflId, - name: fileName, - ext, - size: f.atflFlieSz ?? 0, - uploader: det.rgtrNm ?? "", - atflCd: f.atflCd ?? "", - atflId: f.atflId ?? "", - }; - }), - ); - } - - // 수정모드시 기타첨부 그룹아이디 적용 (중복 매핑 방지) - // - det.atchFileGroupId 또는 det.atflCd 우선, 없으면 etcFileList 첫 항목의 atflCd 사용 - const existingGroup = - det.atchFileGroupId || (Array.isArray(etcFileList) && etcFileList[0]?.atflCd) || ""; - //console.log("etcFileGroupIdRef.current 초기화1:", existingGroup); - if (existingGroup && etcFileGroupIdRef.current !== existingGroup) { - etcFileGroupIdRef.current = existingGroup; - setEtcFileGroupId((prev) => prev || existingGroup); - } - // console.log("etcFileGroupIdRef.current 초기화:", etcFileGroupIdRef.current); - - // 수정모드인 경우 서버가 전달한 업무구분(단일 혹은 콤마로 결합된 복수)을 상태로 복원 - setTaskSeComCd(det.taskSeComCd); - setTaskSeComCdNm(det.taskSeCdNm); - - // 매핑 완료 플래그 설정 — 이후 동일 컴포넌트 인스턴스에서 재매핑 방지 - initialMappedRef.current = true; - - //수정모드 필수첨부파일: requiredFiles는 requiredAttachmentDefs 로 초기화되므로 atflCd/atflId만 덮어쓴다 - if (Array.isArray(esntlFileList)) { - setRequiredFiles((prev) => { - const next = { ...prev }; - for (const f of esntlFileList) { - if (!f || typeof f !== "object") continue; - const key = f.esntlAtchFileId ?? f.esntlAtchFileId; - if (!key) continue; - const existing = next[key] ?? { - key, - esntlAtchFileNm: f.esntlAtchFileNm ?? "", - esntlAtchYn: f.esntlAtchYn ?? "N", - esntlAtchFileRmrk: "", - atflCd: "", - atflId: "", - fileName: "", - uploading: false, - }; - - next[key] = { - ...existing, - atflCd: f.atflCd ?? existing.atflCd, - atflId: f.atflId ?? existing.atflId, - fileName: f.orgnlFileNm ?? existing.fileName, - uploading: false, - }; - } - return next; - }); - } - - // 사업규모 매핑 - if (Array.isArray(bizSclList)) { - const map: Record = {}; - for (const row of bizSclList) { - if (!row.cnstpSeCd) continue; - map[row.cnstpSeCd] = { - bscPl: String(row.bscPl ?? row.bscPlanQty ?? ""), - budgetReflct: String(row.budgetReflct ?? row.bgtRfltQty ?? ""), - thtiDsgn: String(row.thtiDsgn ?? row.tstmDesignQty ?? ""), - }; - } - setBzScaleMap(map); - } - - // 총사업비 매핑 - if (Array.isArray(bizCstList)) { - const map: Record = {}; - for (const row of bizCstList) { - if (!row.acntgSeCd) continue; - map[row.acntgSeCd] = { - bscPl: String(row.bscPlanAmt ?? row.bscPl ?? ""), - budgetReflct: String(row.bgtRfltAmt ?? row.budgetReflct ?? ""), - thtiDsgn: String(row.tstmDesignAmt ?? row.thtiDsgn ?? ""), - }; - } - setAmtMap(map); - } - } catch (err) { - console.error("initialData 매핑 실패", err); - } - }, [initialData]); - - /** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */ - const handleDeleteEtcFile = async (fileId: string, atflId: string) => { - try { - if (atflId) { - await attachService.deleteAttachmentByAtflId(atflId); - } - } catch (error) { - console.error("기타 첨부파일 삭제 실패", error); - alert("파일 삭제 중 오류가 발생했습니다."); - return; - } - - setEtcFiles((prev) => { - const next = prev.filter((x) => x.id !== fileId); - if (next.length === 0) { - setEtcFileGroupId(""); - etcFileGroupIdRef.current = ""; - } - return next; - }); - }; - - const handleRequiredFileChange = ( - key: RequiredAttachKey, - e: React.ChangeEvent, - ) => { - const file = e.target.files?.[0]; - if (!file) return; - uploadRequiredFile(file, key); - e.target.value = ""; - }; - - const uploadEtcFile = (file: File): Promise => - new Promise((resolve, reject) => { - let uploadedAtflCd = ""; - let uploadedAtflId = ""; - console.log("기타첨부파일 업로드 시작:", file.name, etcFileGroupIdRef.current); - const upload = new Upload(file, { - endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, - chunkSize: 10 * 1024 * 1024, - headers: { - [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", - [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", - }, - metadata: { - filename: file.name, - filetype: file.type, - atflCd: etcFileGroupIdRef.current || "", - }, - onError(error: Error) { - reject(error); - }, - onAfterResponse(_req: unknown, res: HttpResponse) { - const fileInfo = res.getHeader("X-File-Info"); - if (typeof fileInfo !== "string") return; - - const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string }; - uploadedAtflCd = parsed.atflCd ?? ""; - uploadedAtflId = parsed.atflId ?? ""; - - if (uploadedAtflCd) { - etcFileGroupIdRef.current = uploadedAtflCd; - setEtcFileGroupId((prev) => prev || uploadedAtflCd); - } - }, - onSuccess() { - const ext = file.name.includes(".") ? (file.name.split(".").pop() ?? "") : ""; - resolve({ - id: uploadedAtflId || `${Date.now()}_${Math.random()}`, - name: file.name, - ext, - size: formatFileSize(file.size), - uploader: userInfo?.userName ?? "알 수 없음", - atflCd: uploadedAtflCd, - atflId: uploadedAtflId, - }); - }, - }); - - upload.start(); - }); - - const handleEtcFilesChange = async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []); - if (files.length === 0) return; - - setEtcUploading(true); - const uploadedRows: EtcFile[] = []; - const failedNames: string[] = []; - - for (const file of files) { - try { - const uploaded = await uploadEtcFile(file); - uploadedRows.push(uploaded); - } catch (error) { - console.error("기타 첨부파일 업로드 실패", error); - failedNames.push(file.name); - } - } - - if (uploadedRows.length > 0) { - setEtcFiles((prev) => [...prev, ...uploadedRows]); - if (!etcFileGroupId && uploadedRows[0].atflCd) { - setEtcFileGroupId(uploadedRows[0].atflCd); - etcFileGroupIdRef.current = uploadedRows[0].atflCd; - } - // 업로드 후 상세 페이지가 최신화되도록 해당 상세 쿼리 무효화 - try { - if (docId) qc.invalidateQueries({ queryKey: ["instl-sncn-detail", docId] }); - else qc.invalidateQueries({ queryKey: ["instl-sncn-detail"] }); - } catch (_e) { - // noop: 무효화 실패는 치명적이지 않음 - } - } - - if (failedNames.length > 0) { - alert(`일부 파일 업로드에 실패했습니다. (${failedNames.join(", ")})`); - } - - setEtcUploading(false); - e.target.value = ""; - }; - - /* ── 체크리스트 ── */ - type CheckResultVal = "Y" | "N" | null; - const [checkResults, setCheckResults] = useState>({}); - const [checkComments, setCheckComments] = useState>({}); - const cklstRowRefs = useRef>({}); - const [pendingInvalidKey, setPendingInvalidKey] = useState(null); - - const { data: cklstTree = [] } = useQuery({ - queryKey: ["cklstTree", docSeComCd, taskSeComCd], - queryFn: async () => { - // support comma-separated task codes (e.g. "RE,SP") by splitting - const raw = taskSeComCd ?? ""; - const codes = raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - - if (codes.length === 0) return [] as CklstTreeNode[]; - if (codes.length === 1) { - return await cklstMngService.findTree({ docSeComCd, taskSeComCd: codes[0] }); - } - - const results = await Promise.all( - codes.map((code) => cklstMngService.findTree({ docSeComCd, taskSeComCd: code })), - ); - const flat = results.flat(); - // dedupe by cklstRvwSn, keep first occurrence - const map = new Map(); - for (const row of flat) { - if (!map.has(row.cklstRvwSn)) map.set(row.cklstRvwSn, row); - } - return Array.from(map.values()); - }, - enabled: Boolean(docSeComCd) && Boolean(taskSeComCd), - staleTime: Infinity, - }); - - const cklstRows = useMemo(() => { - // bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함 - // cklstTree 내의 피드 taskSeComCd 에 따라서 그룹핑 되도록 해줘 - - const useRows = selectedTaskCode - ? cklstTree.filter((r) => (r as any).taskSeComCd === selectedTaskCode) - : cklstTree; - if (useRows.length === 0) return [] as CklstTreeNode[]; - - // artcl 단위로 분리하지 않고 전체 rows에서 단일 childMap을 구성한다. - // upCklstRvwSn이 다른 artcl 소속 노드를 부모로 가질 수 있으므로 - // artcl 경계를 넘는 부모-자식 관계도 처리해야 한다. - const childMap = new Map(); - for (const row of useRows) { - const parent = row.upCklstRvwSn ?? null; - if (!childMap.has(parent)) childMap.set(parent, []); - childMap.get(parent)!.push(row); - } - - for (const list of childMap.values()) { - list.sort((a, b) => - a.cklstArtclSn !== b.cklstArtclSn - ? a.cklstArtclSn - b.cklstArtclSn - : a.sortSeq !== b.sortSeq - ? a.sortSeq - b.sortSeq - : a.cklstRvwSn - b.cklstRvwSn, - ); - } - - const ordered: CklstTreeNode[] = []; - const walk = (parent: number | null) => { - const children = childMap.get(parent) ?? []; - for (const child of children) { - ordered.push(child); - walk(child.cklstRvwSn); - } - }; - walk(null); - - return ordered; - }, [cklstTree, selectedTaskCode]); - - // when cklstTree changes, default selectedTaskCode to first available code - useEffect(() => { - const codes = Array.from( - new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)), - ); - - if (codes.length === 0) { - if (selectedTaskCode !== null) setSelectedTaskCode(null); - return; - } - - // if there's no selection yet, or the current selection is not in the new codes, - // pick the first available code - if (!selectedTaskCode || !codes.includes(selectedTaskCode)) { - setSelectedTaskCode(codes[0]); - } - }, [cklstTree, selectedTaskCode]); - - const cklstKey = (row: CklstTreeNode, rvwStpComCd?: string) => { - const stepCode = rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD; - const taskCode = (row as any).taskSeComCd ?? ""; - return `${taskCode}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${stepCode}`; - }; - - useEffect(() => { - setCheckResults((prev) => { - const next: Record = { ...prev }; - let changed = false; - - cklstRows.forEach((row) => { - visibleReviewStepCodes.forEach((stepCode) => { - const key = cklstKey(row, stepCode); - if (!Object.prototype.hasOwnProperty.call(next, key)) { - next[key] = null; - changed = true; - } - }); - }); - - // 행이 없어졌을 때 stale key만 정리 - // build liveKeys from full cklstTree so inputs for other taskSeComCd are preserved - const liveKeys = new Set(); - (cklstTree ?? []).forEach((row) => { - visibleReviewStepCodes.forEach((stepCode) => { - liveKeys.add(cklstKey(row, stepCode)); - }); - }); - - Object.keys(next).forEach((key) => { - const keyStep = key.split("_").pop(); - if ( - keyStep && - visibleReviewStepCodes.includes(keyStep as ReviewStepCode) && - !liveKeys.has(key) - ) { - delete next[key]; - changed = true; - } - }); - - if (!changed) { - const prevKeys = Object.keys(prev); - const nextKeys = Object.keys(next); - if (prevKeys.length !== nextKeys.length) { - changed = true; - } else { - for (const key of nextKeys) { - if (prev[key] !== next[key]) { - changed = true; - break; - } - } - } - } - - return changed ? next : prev; - }); - }, [cklstTree, visibleReviewStepCodes]); - - // when a previously-missing invalid key is set (due to switching task code), - // try to scroll/focus it after the rows have re-rendered - useEffect(() => { - if (!pendingInvalidKey) return; - const el = cklstRowRefs.current[pendingInvalidKey]; - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - const firstRadio = el.querySelector( - `input[name="chk_${pendingInvalidKey}"]`, - ); - firstRadio?.focus(); - setPendingInvalidKey(null); - } - // otherwise, wait for cklstRows update (effect will re-run when pendingInvalidKey remains set) - }, [pendingInvalidKey, cklstRows]); - - // 수정 모드에서 서버의 체크리스트 결과값을 폼 상태로 반영 - useEffect(() => { - if (!initialData || cklstRows.length === 0) return; - const results = normalizeChecklistResultRows(initialData); - if (!Array.isArray(results) || results.length === 0) return; - - const lookup = new Map(); - const commentLookup = new Map(); - const stepCodesInResult = new Set(); - for (const r of results) { - const stepCode = (r.rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD) as ReviewStepCode; - if (!REVIEW_STEP_CODES.includes(stepCode)) continue; - stepCodesInResult.add(stepCode); - const key = `${r.taskSeComCd ?? ""}_${r.cklstSn}_${r.cklstArtclSn ?? ""}_${r.cklstRvwSn}_${stepCode}`; - if (r.rvwRsltAnmlyYn === "Y" || r.rvwRsltAnmlyYn === "N") lookup.set(key, r.rvwRsltAnmlyYn); - if (r.picRvwCn) commentLookup.set(key, String(r.picRvwCn)); - } - - setCheckResults((prev) => { - const next = { ...prev }; - let changed = false; - const targetStepCodes = Array.from( - new Set([...visibleReviewStepCodes, ...stepCodesInResult]), - ); - - for (const row of cklstRows) { - targetStepCodes.forEach((stepCode) => { - const k = cklstKey(row, stepCode); - const v = lookup.get(k); - // only apply server-provided value when we don't already have a user-entered value - if (v && (next[k] === null || next[k] === undefined)) { - next[k] = v as CheckResultVal; - changed = true; - } - }); - } - return changed ? next : prev; - }); - // set comment state separately to avoid rerendering conflicts - setCheckComments((prev) => { - const next: Record = { ...prev }; - let changed = false; - for (const [k, v] of commentLookup.entries()) { - // only set comment if none exists yet (preserve user edits) - if (!next[k]) { - next[k] = v; - changed = true; - } - } - return changed ? next : prev; - }); - }, [initialData, cklstRows, visibleReviewStepCodes]); - - const cklstStats = useMemo(() => { - if (!selectedReviewStep) { - return { total: 0, completed: 0, percent: 0 }; - } - // only include rows that are marked for checkbox use (bchckUseYn === "Y") - const relevantRows = cklstRows.filter((row) => row.bchckUseYn === "Y"); - const total = relevantRows.length; - const completed = relevantRows.reduce((acc, row) => { - const val = checkResults[cklstKey(row, selectedReviewStep)] ?? ""; - return acc + (val ? 1 : 0); - }, 0); - const percent = total > 0 ? Math.round((completed / total) * 100) : 0; - return { total, completed, percent }; - }, [cklstRows, checkResults, selectedReviewStep]); - - const toCklstSaveDtoList = () => { - const persistedStepCodeSet = new Set(); - normalizeChecklistResultRows(initialData).forEach((r: any) => { - const code = r?.rvwStpComCd as ReviewStepCode | undefined; - if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code); - }); - // Always persist currently visible review steps as well - visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code)); - - // only persist rows that are enabled for checkbox (`bchckUseYn === "Y") - // only persist rows that are enabled for checkbox (`bchckUseYn === "Y") - // Use full cklstTree so items not currently visible (other task codes) are included - const relevantRows = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y"); - - // Ensure we generate DTOs grouped by all known task codes (TASK_CODE_LABELS keys) - const allTaskCodes = Object.keys(TASK_CODE_LABELS); - - return allTaskCodes.flatMap((taskCode) => - Array.from(persistedStepCodeSet).flatMap((rvwStpComCd) => - relevantRows - .filter((row) => ((row as any).taskSeComCd ?? "") === taskCode) - .map((row) => ({ - taskSeComCd: taskCode, - cklstSn: row.cklstSn, - cklstArtclSn: row.cklstArtclSn, - cklstRvwSn: row.cklstRvwSn, - rvwStpComCd: rvwStpComCd, - selected: checkResults[cklstKey(row, rvwStpComCd)] ?? null, - picRvwCn: checkComments[cklstKey(row, rvwStpComCd)] ?? null, - })), - ), - ); - }; - - // removed map-shaped payload generation — server now expects `cklstResultList` only - - const toEsntlAtchFileSaveDtoList = () => - Object.values(requiredFiles) - .filter((item) => Boolean(item.atflCd) && Boolean(item.atflId)) - .map((item) => ({ - esntlAtchFileId: item.key, - atchFileGroupId: item.atflCd, - })); - - /* ── 사업규모 (공종구분 트리 연동) ── */ - // cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map - const [bzScaleMap, setBzScaleMap] = useState>({}); - - /* ── 공종구분 트리 로드 ── */ - const { data: cnstpTree = [] } = useQuery({ - queryKey: ["cnstpTree"], - queryFn: () => cnstpSeMngService.findTree(), - staleTime: Infinity, - }); - - /* ── 공종구분 리프 노드 집합 ── */ - const cnstpLeafSet = useMemo(() => { - const parentCodes = new Set(cnstpTree.map((n) => n.upCnstpCd).filter(Boolean) as string[]); - return new Set(cnstpTree.filter((n) => !parentCodes.has(n.cnstpSeCd)).map((n) => n.cnstpSeCd)); - }, [cnstpTree]); - - const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number => - cnstpTree - .filter( - (n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd), - ) - .reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0); - - const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number => - cnstpLeafSet.has(node.cnstpSeCd) - ? num((bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col]) - : subtotalScale(node, col); - - const handleScaleInput = (cnstpSeCd: string, col: keyof BzScaleRow, val: string) => - setBzScaleMap((prev) => ({ - ...prev, - [cnstpSeCd]: { ...(prev[cnstpSeCd] ?? emptyScale()), [col]: val }, - })); - - /** 사업규모 맵 -> 백엔드 저장용 배열 DTO 변환 */ - const toBzScaleSaveDtoList = (): InstlSncnBzScaleSaveDto[] => - cnstpTree - .filter((node) => cnstpLeafSet.has(node.cnstpSeCd)) - .map((node) => { - const row = bzScaleMap[node.cnstpSeCd] ?? emptyScale(); - const bscPl = num(row.bscPl); - const budgetReflct = num(row.budgetReflct); - const thtiDsgn = num(row.thtiDsgn); - - return { - cnstpSeCd: node.cnstpSeCd, - bscPl, - budgetReflct, - thtiDsgn, - diff: thtiDsgn - budgetReflct, - }; - }); - - /* ── 총사업비 (회계구분 트리 연동) ── */ - // acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map - const [amtMap, setAmtMap] = useState>({}); - - /* ── 회계구분 트리 로드 ── */ - const { data: acntgTree = [] } = useQuery({ - queryKey: ["acntgTree"], - queryFn: () => acntgSeMngService.findTree(), - staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요 - }); - - /* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */ - const leafSet = useMemo(() => { - const parentCodes = new Set(acntgTree.map((n) => n.upAcntgSeCd).filter(Boolean) as string[]); - return new Set(acntgTree.filter((n) => !parentCodes.has(n.acntgSeCd)).map((n) => n.acntgSeCd)); - }, [acntgTree]); - - /** - * treePath 기반 소계 계산 - * - 이 노드의 acntgSeCd 가 treePath 안에 포함된 리프 노드들의 합산 - * - 새로운 하위 레벨이 추가돼도 자동으로 포함됨 - */ - const subtotal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number => - acntgTree - .filter((n) => leafSet.has(n.acntgSeCd) && n.treePath.split("|").includes(node.acntgSeCd)) - .reduce((sum, n) => sum + num((amtMap[n.acntgSeCd] ?? emptyAmt())[col]), 0); - - /** 노드 값 반환 — 리프이면 입력값, 부모이면 소계 */ - const nodeVal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number => - leafSet.has(node.acntgSeCd) - ? num((amtMap[node.acntgSeCd] ?? emptyAmt())[col]) - : subtotal(node, col); - - /** 단일 셀 입력 핸들러 */ - const handleAmtInput = (acntgSeCd: string, col: keyof BzAmtRow, val: string) => - setAmtMap((prev) => ({ - ...prev, - [acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val }, - })); - - /** 총사업비 맵 -> 백엔드 저장용 배열 DTO 변환 */ - const toBizCstSaveDtoList = (): InstlSncnBizCstSaveRequestDto[] => - acntgTree - .filter((node) => leafSet.has(node.acntgSeCd)) - .map((node) => { - const row = amtMap[node.acntgSeCd] ?? emptyAmt(); - return { - acntgSeCd: node.acntgSeCd, - bscPlanAmt: num(row.bscPl), - bgtRfltAmt: num(row.budgetReflct), - tstmDesignAmt: num(row.thtiDsgn), - }; - }); - - /** - * indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환 - * (공백 4칸 = 1 depth = 1rem) - */ - const indentPx = (indNm: string): number => { - const spaces = indNm.length - indNm.trimStart().length; - return (spaces / 4) * 16; // 4공백 = 16px - }; - - /** 트리 행 스타일 분류 */ - const rowStyle = (node: AcntgSeMngTreeDto) => { - if (node.treeLevel === 1) return "bg-blue-50 font-bold"; - if (node.treeLevel === 2) return "bg-gray-100 font-semibold"; - if (!leafSet.has(node.acntgSeCd)) return "font-semibold"; // 중간 부모 (예: 하수관로) - return ""; - }; - - /** 공종 트리 행 스타일 분류 */ - const scaleRowStyle = (node: CnstpSeMngTreeDto) => { - if (node.treeLevel === 1) return "bg-gray-50 font-semibold"; - if (!cnstpLeafSet.has(node.cnstpSeCd)) return "font-semibold"; - return ""; - }; - - /* ── 증감 라벨 ── */ - const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v)); - - /* ── 검증 ── */ - function fnValidation(mode: "SAVE" | "PRESENTN") { - if (!bzNm.trim()) { - alert("사업명을 입력하세요."); - return false; - } - if (!bzBgngYr || !bzEndYr) { - alert("사업기간을 입력하세요."); - return false; - } - if (!bzCn.trim()) { - alert("사업내용을 입력하세요."); - return false; - } - if (mode === "PRESENTN" && privacyAgree !== "Y") { - alert("개인정보 제공에 동의하셔야 제출이 가능합니다."); - return false; - } - - if (!editableReviewStep) { - alert("현재 사용자 권한/진행상태 조합에서 입력 가능한 체크리스트 단계가 없습니다."); - return false; - } - - // 현재 입력 단계의 체크리스트 미선택 항목이 있으면 진행 불가 - /* - const hasUncheckedChecklist = cklstRows.some( - (row) => !checkResults[cklstKey(row, editableReviewStep)], - ); - if (hasUncheckedChecklist) { - alert("체크리스트를 모두 체크하세요."); - return false; - } - */ - // 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증 - if (mode === "SAVE") { - const missingRequiredFile = requiredAttachmentDefs.find((item) => { - if (item.esntlAtchYn !== "Y") return false; - const uploaded = requiredFiles[item.esntlAtchFileId]; - return !(uploaded?.atflCd && uploaded?.atflId); - }); - if (missingRequiredFile) { - alert(`${missingRequiredFile.esntlAtchFileNm} 파일을 첨부하세요.`); - return false; - } - - // 저장 시: bchckUseYn =="Y" 인 경우, 화면에 표시되는 모든 review step에 대해 - // 검토 결과가 선택되어 있어야 함 (visibleReviewStepCodes 기준) - // check across all checklist items (not only currently visible rows) - const allRelevant = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y"); - const missingRow = allRelevant.find((row) => - visibleReviewStepCodes.some((step) => !checkResults[cklstKey(row, step)]), - ); - if (missingRow) { - const missingStep = visibleReviewStepCodes.find( - (step) => !checkResults[cklstKey(missingRow, step)], - )!; - const invalidKey = cklstKey(missingRow, missingStep); - console.warn("검토 결과 미선택 항목 발견:", invalidKey, missingRow, missingStep); - - const invalidRowEl = cklstRowRefs.current[invalidKey]; - if (invalidRowEl) { - invalidRowEl.scrollIntoView({ behavior: "smooth", block: "center" }); - const firstRadio = invalidRowEl.querySelector( - `input[name="chk_${invalidKey}"]`, - ); - firstRadio?.focus(); - } else { - // row not visible: switch header filter to the missing row's task code, - // then focus after render via pendingInvalidKey - const taskCode = (missingRow as any).taskSeComCd ?? null; - if (taskCode) setSelectedTaskCode(taskCode); - setPendingInvalidKey(invalidKey); - } - - alert(`체크리스트 항목 "${missingRow.rvwCn}"의 검토결과를 선택하세요.`); - return false; - } - } - - return true; - } - - const toInstlSncnMngPayload = ( - prgrsSttsComCd: "SV" | "ST" | "ER", - ): InstlSncnMngSaveRequestDto => ({ - instlSncnDocNo: docId ?? "", - admdstCd: "", - envoSeComCd: currentDet?.envoSeComCd ?? "", - sncnSeComCd: sncnSeComCd || DEFAULT_SNCN_SE_COM_CD, - taskSeComCd, - bgtBizCd: "", - bgtAplyYr: "", - bizNm: bzNm, - bizBgngYr: bzBgngYr, - bizEndYr: bzEndYr, - bizCn: bzCn, - aplcntNm: aplcntNm || userInfo?.userName || "", - aplcntMblTelno: aplcntMblTelno || (userInfo?.mphn ?? userInfo?.telno ?? ""), - aplcntOgdpNm: aplcntOgdpNm || (userInfo?.instSeNm ?? ""), - aplcntEmlAddr: aplcntEmlAddr || (userInfo?.email ?? ""), - dsgnrNm, - dsgnrTelno, - dsgnrOgdpNm: dsgnrPsitn, - dsgnrEmlAddr, - atchFileGroupId: etcFileGroupId, - prgrsSttsComCd, - }); - - async function handleSave() { - if (!fnValidation("SAVE")) return; - // 저장 시에는 `cklstResultList`만 전송합니다 (map 필드 제거) - const payload: InstlSncnApplySaveRequestDto = { - instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장) - bzScaleList: toBzScaleSaveDtoList(), - bizCstList: toBizCstSaveDtoList(), - cklstResultList: toCklstSaveDtoList(), - esntlAtchFileList: toEsntlAtchFileSaveDtoList(), - }; - - try { - const issuedDocNo = await instlSncnService.saveApply(payload); - alert(`저장되었습니다. 문서번호: ${issuedDocNo}`); - } catch (error) { - console.error("저장 API 호출 실패", error); - const serverMessage = - axios.isAxiosError(error) && error.response?.data?.message - ? (error.response.data as { message: string }).message - : "저장 중 오류가 발생했습니다."; - alert(serverMessage); - } - } - - async function handleSubmit() { - if (!fnValidation("SAVE")) return; - - const payload: InstlSncnApplySaveRequestDto = { - instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장) - bzScaleList: toBzScaleSaveDtoList(), - bizCstList: toBizCstSaveDtoList(), - cklstResultList: toCklstSaveDtoList(), - esntlAtchFileList: toEsntlAtchFileSaveDtoList(), - }; - - try { - const issuedDocNo = await instlSncnService.saveApply(payload); - alert(`제출 되었습니다. 문서번호: ${issuedDocNo}`); - } catch (error) { - console.error("저장 API 호출 실패", error); - const serverMessage = - axios.isAxiosError(error) && error.response?.data?.message - ? (error.response.data as { message: string }).message - : "저장 중 오류가 발생했습니다."; - alert(serverMessage); - } - } - - /* ---------------------------------------------------------------- */ - return ( -
- {/* ── 프로세스 스텝 바 ── */} -
-
- {["설치인가 신청", "기술검토/조치계획 검토", "기술검토 회신", "인가승인"].map( - (label, i) => ( - - - - {label} - - {i < 3 && ›} - - ), - )} -
-
- - {/* ── 타이틀 + 액션 버튼 ── */} -
-

■ 설치인가 신청/수정

-
- - -
-
- - {/* ── 기본 정보 ── */} -
-

기본 정보

- - - - - - - - - - -
- * 사업명 - -
- setBzNm(e.target.value)} - /> - -
-
- * 사업기간 - -
- setBzBgngYr(e.target.value)} - /> - ~ - setBzEndYr(e.target.value)} - /> -
-
- * 사업내용 - -