From df4208d4e1032580b245d989185bce2d5c99158a Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 21 May 2026 17:38:50 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20feat:=20=EA=B2=B0=EC=9E=AC=EC=84=A0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/elapr/api/elapr-apln-service.ts | 12 +- apps/as/src/entities/elapr/api/queries.ts | 29 +++- apps/as/src/entities/elapr/index.ts | 19 ++- .../elapr/model/elapr-apln-interface.ts | 26 ++- .../entities/user-organization/api/queries.ts | 15 ++ .../api/user-ognz-service.ts | 18 ++ .../src/entities/user-organization/index.ts | 7 + .../model/ognz-user-tree-interface.ts | 23 +++ .../lib/build-save-multi-payload.ts | 70 ++++++++ .../lib/filter-ognz-user-tree.ts | 44 +++++ .../lib/has-unsaved-changes.ts | 32 ++++ .../lib/use-apln-rows.ts | 87 ++++++---- .../lib/validators.ts | 73 +++++--- .../model/apln-row-types.ts | 4 +- .../model/mock-reviewers.ts | 17 -- .../model/reviewer-option.ts | 15 ++ .../ui/aprvr-picker-dialog.tsx | 141 +++++++++------ .../ui/elapr-apln-grid.tsx | 161 +++++++----------- .../ui/ognz-user-tree-node.tsx | 116 +++++++++++++ .../ui/ognz-user-tree.tsx | 71 ++++++++ .../ui/sortable-apln-row.tsx | 2 +- apps/as/src/shared/config/api.ts | 1 + packages/common/components/src/ui/index.ts | 1 + 23 files changed, 742 insertions(+), 242 deletions(-) create mode 100644 apps/as/src/entities/user-organization/api/queries.ts create mode 100644 apps/as/src/entities/user-organization/api/user-ognz-service.ts create mode 100644 apps/as/src/entities/user-organization/index.ts create mode 100644 apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts create mode 100644 apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts create mode 100644 apps/as/src/pages/approval-line-management/lib/filter-ognz-user-tree.ts create mode 100644 apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts delete mode 100644 apps/as/src/pages/approval-line-management/model/mock-reviewers.ts create mode 100644 apps/as/src/pages/approval-line-management/model/reviewer-option.ts create mode 100644 apps/as/src/pages/approval-line-management/ui/ognz-user-tree-node.tsx create mode 100644 apps/as/src/pages/approval-line-management/ui/ognz-user-tree.tsx diff --git a/apps/as/src/entities/elapr/api/elapr-apln-service.ts b/apps/as/src/entities/elapr/api/elapr-apln-service.ts index 856cf4280..a61b95f95 100644 --- a/apps/as/src/entities/elapr/api/elapr-apln-service.ts +++ b/apps/as/src/entities/elapr/api/elapr-apln-service.ts @@ -5,6 +5,7 @@ import type { PageResponse } from "@/shared/model"; import type { ElaprApln, ElaprAplnListParams, + ElaprAplnReorderRequest, ElaprAplnSaveRequest, ElaprAplnService, ElaprAplnUpdateRequest, @@ -22,8 +23,8 @@ async function findById(aplnId: string): Promise { return response.data; } -async function create(body: ElaprAplnSaveRequest): Promise { - const response = await client.post(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body); +async function saveMulti(body: ElaprAplnSaveRequest[]): Promise { + const response = await client.post(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body); return response.data; } @@ -39,10 +40,15 @@ async function remove(aplnId: string): Promise { await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`); } +async function reorder(body: ElaprAplnReorderRequest): Promise { + await client.put(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/reorder`, body); +} + export const elaprAplnService = { findPage, findById, - create, + saveMulti, update, remove, + reorder, } satisfies ElaprAplnService; diff --git a/apps/as/src/entities/elapr/api/queries.ts b/apps/as/src/entities/elapr/api/queries.ts index 0e1304977..59a6bb91b 100644 --- a/apps/as/src/entities/elapr/api/queries.ts +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -4,6 +4,7 @@ import { ENDPOINT } from "@/shared/config"; import type { ElaprAplnListParams, + ElaprAplnReorderRequest, ElaprAplnSaveRequest, ElaprAplnUpdateRequest, } from "../model/elapr-apln-interface"; @@ -94,11 +95,16 @@ export function useElaprApln(aplnId: string | null | undefined) { }); } -export function useCreateElaprApln() { +export function useSaveMultiElaprApln() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: ElaprAplnSaveRequest) => elaprAplnService.create(body), + mutationFn: ({ + body, + }: { + body: ElaprAplnSaveRequest[]; + elaprTmpltId: string; + }) => elaprAplnService.saveMulti(body), onSuccess: (_data, { elaprTmpltId }) => { queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); queryClient.invalidateQueries({ @@ -149,3 +155,22 @@ export function useDeleteElaprApln() { }, }); } + +export function useReorderElaprApln() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + body, + }: { + body: ElaprAplnReorderRequest; + elaprTmpltId: string; + }) => elaprAplnService.reorder(body), + onSuccess: (_data, { elaprTmpltId }) => { + queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }], + }); + }, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index cc776a82d..caa639e51 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -1,7 +1,6 @@ export { elaprAplnService } from "./api/elapr-apln-service"; export { elaprTmpltService } from "./api/elapr-tmplt-service"; export { - useCreateElaprApln, useCreateElaprTmplt, useDeleteElaprApln, useDeleteElaprTmplt, @@ -9,15 +8,21 @@ export { useElaprAplnPage, useElaprTmplt, useElaprTmpltPage, + useReorderElaprApln, + useSaveMultiElaprApln, useUpdateElaprApln, useUpdateElaprTmplt, } from "./api/queries"; -export type { - ElaprApln, - ElaprAplnListParams, - ElaprAplnSaveRequest, - ElaprAplnService, - ElaprAplnUpdateRequest, +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 { ElaprTmplt, diff --git a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts index 3d328d3f4..a41add2cd 100644 --- a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts @@ -1,9 +1,19 @@ import type { PageableParams, PageResponse } from "@/shared/model"; +export const ELAPR_APLN_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type ElaprAplnSaveMode = + (typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE]; + export interface ElaprApln { aplnId: string; elaprTmpltId: string; aprvrUuid?: string; + aprvrNm?: string; atrzSeComCd?: string; atrzSeComNm?: string; atrzSeqNo?: number; @@ -18,7 +28,9 @@ export interface ElaprAplnListParams extends PageableParams { } export interface ElaprAplnSaveRequest { - elaprTmpltId: string; + mode: ElaprAplnSaveMode; + aplnId?: string; + elaprTmpltId?: string; aprvrUuid?: string; atrzSeComCd?: string; atrzSeqNo?: number; @@ -31,10 +43,20 @@ export interface ElaprAplnUpdateRequest { atrzSeqNo?: number; } +export interface ElaprAplnReorderItem { + aplnId: string; + atrzSeqNo: number; +} + +export interface ElaprAplnReorderRequest { + items: ElaprAplnReorderItem[]; +} + export interface ElaprAplnService { findPage(params: ElaprAplnListParams): Promise>; findById(aplnId: string): Promise; - create(body: ElaprAplnSaveRequest): Promise; + saveMulti(body: ElaprAplnSaveRequest[]): Promise; update(aplnId: string, body: ElaprAplnUpdateRequest): Promise; remove(aplnId: string): Promise; + reorder(body: ElaprAplnReorderRequest): Promise; } diff --git a/apps/as/src/entities/user-organization/api/queries.ts b/apps/as/src/entities/user-organization/api/queries.ts new file mode 100644 index 000000000..726903dd7 --- /dev/null +++ b/apps/as/src/entities/user-organization/api/queries.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { userOgnzService } from "./user-ognz-service"; + +const userOgnzTreeBaseKey = [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION, "treeuser"] as const; + +export function useOgnzUserTree(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: userOgnzTreeBaseKey, + queryFn: () => userOgnzService.findOgnzUserTree(), + enabled: options?.enabled ?? true, + }); +} diff --git a/apps/as/src/entities/user-organization/api/user-ognz-service.ts b/apps/as/src/entities/user-organization/api/user-ognz-service.ts new file mode 100644 index 000000000..55a4b0355 --- /dev/null +++ b/apps/as/src/entities/user-organization/api/user-ognz-service.ts @@ -0,0 +1,18 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + OgnzUserTreeResponseDto, + UserOgnzService, +} from "../model/ognz-user-tree-interface"; + +async function findOgnzUserTree(): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`, + ); + return response.data; +} + +export const userOgnzService = { + findOgnzUserTree, +} satisfies UserOgnzService; diff --git a/apps/as/src/entities/user-organization/index.ts b/apps/as/src/entities/user-organization/index.ts new file mode 100644 index 000000000..26c43cfa7 --- /dev/null +++ b/apps/as/src/entities/user-organization/index.ts @@ -0,0 +1,7 @@ +export { useOgnzUserTree } from "./api/queries"; +export { userOgnzService } from "./api/user-ognz-service"; +export type { + OgnzUserTreeResponseDto, + OgnzUserTreeUserDto, + UserOgnzService, +} from "./model/ognz-user-tree-interface"; diff --git a/apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts b/apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts new file mode 100644 index 000000000..71dcc5833 --- /dev/null +++ b/apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts @@ -0,0 +1,23 @@ +/** OgnzUserTreeResponseDto.UserDto */ +export interface OgnzUserTreeUserDto { + userUnqId: string; + userNm: string; + jbpsSeCd: string; + jbgdSeCd: string; +} + +/** OgnzUserTreeResponseDto */ +export interface OgnzUserTreeResponseDto { + ognzSn: number; + upOgnzSn: number | null; + ognzNm: string; + ognzExpln?: string; + sortSeq?: number; + useYn?: string; + users: OgnzUserTreeUserDto[]; + children: OgnzUserTreeResponseDto[]; +} + +export interface UserOgnzService { + findOgnzUserTree(): Promise; +} diff --git a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts new file mode 100644 index 000000000..0c66b7623 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts @@ -0,0 +1,70 @@ +import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } 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 + ); +} + +function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolean { + if (row.isDirty) return true; + if (!row.aplnId) return false; + + const serverRow = serverRows.find((s) => s.aplnId === row.aplnId); + if (!serverRow) return false; + + return rowDiffersFromServer(row, serverRow); +} + +export function buildSaveMultiPayload( + rows: ElaprAplnRow[], + elaprTmpltId: string, + serverRows: ElaprApln[], +): ElaprAplnSaveRequest[] { + const payload: ElaprAplnSaveRequest[] = []; + + for (const row of rows) { + if (row.isDeleted && row.aplnId) { + payload.push({ + mode: ELAPR_APLN_SAVE_MODE.DELETE, + aplnId: row.aplnId, + }); + } + } + + const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) { + payload.push({ + mode: ELAPR_APLN_SAVE_MODE.CREATE, + elaprTmpltId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + }); + continue; + } + + if (!row.aplnId) continue; + + if (shouldIncludeUpdate(row, serverRows)) { + payload.push({ + mode: ELAPR_APLN_SAVE_MODE.UPDATE, + aplnId: row.aplnId, + elaprTmpltId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + }); + } + } + + return payload; +} diff --git a/apps/as/src/pages/approval-line-management/lib/filter-ognz-user-tree.ts b/apps/as/src/pages/approval-line-management/lib/filter-ognz-user-tree.ts new file mode 100644 index 000000000..cf8be566e --- /dev/null +++ b/apps/as/src/pages/approval-line-management/lib/filter-ognz-user-tree.ts @@ -0,0 +1,44 @@ +import type { OgnzUserTreeResponseDto } from "@/entities/user-organization"; + +export function filterOgnzUserTree( + nodes: OgnzUserTreeResponseDto[], + keyword: string, +): OgnzUserTreeResponseDto[] { + const q = keyword.trim().toLowerCase(); + if (!q) return nodes; + + return nodes + .map((node) => { + const ognzMatches = node.ognzNm.toLowerCase().includes(q); + const filteredUsers = ognzMatches + ? node.users + : node.users.filter((user) => user.userNm.toLowerCase().includes(q)); + const filteredChildren = filterOgnzUserTree(node.children ?? [], keyword); + + if (ognzMatches || filteredUsers.length > 0 || filteredChildren.length > 0) { + return { + ...node, + users: filteredUsers, + children: filteredChildren, + }; + } + + return null; + }) + .filter((node): node is OgnzUserTreeResponseDto => node !== null); +} + +export function collectExpandedOgnzSnIds(nodes: OgnzUserTreeResponseDto[]): Set { + const ids = new Set(); + + function walk(list: OgnzUserTreeResponseDto[]) { + for (const node of list) { + const hasContent = (node.users?.length ?? 0) > 0 || (node.children?.length ?? 0) > 0; + if (hasContent) ids.add(node.ognzSn); + walk(node.children ?? []); + } + } + + walk(nodes); + return ids; +} 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 new file mode 100644 index 000000000..46efa59f3 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts @@ -0,0 +1,32 @@ +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/lib/use-apln-rows.ts b/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts index 26dda19be..01c71ce3b 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts +++ b/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts @@ -9,10 +9,17 @@ import type { ElaprApln } from "@/entities/elapr"; import type { ElaprAplnRow } from "../model/apln-row-types"; import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { validateAtrzOrder } from "./validators"; +import { getVisibleRows } from "./validators"; function toRow(item: ElaprApln): ElaprAplnRow { - return { ...item, rowKey: item.aplnId, isNew: false, isDirty: false }; + return { + ...item, + rowKey: item.aplnId, + aprvrNm: item.aprvrNm, + isNew: false, + isDirty: false, + isDeleted: false, + }; } interface UseAplnRowsOptions { @@ -39,6 +46,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { }, [elaprTmpltId, serverRows]); const isEditingLocked = editingRowKey != null; + const visibleRows = getVisibleRows(rows); const addRow = useCallback(() => { if (!elaprTmpltId || isEditingLocked) return; @@ -55,6 +63,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { aprvrNm: "", isNew: true, isDirty: true, + isDeleted: false, }, ]); setEditingRowKey(rowKey); @@ -92,8 +101,8 @@ 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, ...restored } = row.snapshot; - return { ...restored, isNew: false, isDirty: false }; + const { snapshot: _s, isNew: _n, isDirty: _d, isDeleted: _del, ...restored } = row.snapshot; + return { ...restored, isNew: false, isDirty: false, isDeleted: false }; } return { ...row, isDirty: false }; }); @@ -101,52 +110,68 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { setEditingRowKey(null); }, [editingRowKey]); - const clearEditing = useCallback(() => { - 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 removeRowLocal = useCallback((rowKey: string) => { - setRows((prev) => prev.filter((r) => r.rowKey !== rowKey)); + const markDeleted = useCallback((rowKey: string) => { + setRows((prev) => { + const target = prev.find((r) => r.rowKey === rowKey); + if (!target) return prev; + + if (target.isNew) { + return prev.filter((r) => r.rowKey !== rowKey); + } + + return prev.map((row) => + row.rowKey === rowKey ? { ...row, isDeleted: true, isDirty: true } : row, + ); + }); setEditingRowKey((key) => (key === rowKey ? null : key)); }, []); const reorderRows = useCallback( - ( - activeKey: string, - overKey: string, - ): { payloadRows?: ElaprAplnRow[]; orderError?: string | null } => { - if (isEditingLocked) return {}; + (activeKey: string, overKey: string): ElaprAplnRow[] => { + if (isEditingLocked) return []; let payloadRows: ElaprAplnRow[] = []; - let orderError: string | null = null; setRows((prev) => { - const oldIndex = prev.findIndex((r) => r.rowKey === activeKey); - const newIndex = prev.findIndex((r) => r.rowKey === overKey); + const visible = getVisibleRows(prev); + const oldIndex = visible.findIndex((r) => r.rowKey === activeKey); + const newIndex = visible.findIndex((r) => r.rowKey === overKey); if (oldIndex < 0 || newIndex < 0) return prev; - const moved = assignAtrzSeqNumbers(arrayMove(prev, oldIndex, newIndex)).map((row) => ({ - ...row, - isDirty: true, - })); + const reorderedVisible = assignAtrzSeqNumbers(arrayMove(visible, oldIndex, newIndex)).map( + (row) => ({ ...row, isDirty: true }), + ); - const error = validateAtrzOrder(moved); - if (error) { - orderError = error; - return prev; - } + const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey)); + const deletedRows = prev.filter((r) => r.isDeleted); + const merged = [...reorderedVisible, ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey))]; - payloadRows = moved; - return moved; + payloadRows = reorderedVisible; + return merged; }); - return { payloadRows, orderError }; + return payloadRows; }, [isEditingLocked], ); return { rows, + visibleRows, setRows, editingRowKey, isEditingLocked, @@ -154,8 +179,8 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { startEdit, updateRow, cancelEdit, - clearEditing, - removeRowLocal, + commitEdit, + markDeleted, reorderRows, }; } diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/pages/approval-line-management/lib/validators.ts index a61de75b8..a136d07d7 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/pages/approval-line-management/lib/validators.ts @@ -1,37 +1,37 @@ import type { ElaprAplnRow } from "../model/apln-row-types"; -export const ATRZ_REVIEWER_CD = "ATRZ01"; -export const ATRZ_FINAL_CD = "ATRZ03"; +export const ATRZ_RECEIVER_CD = "ATRZ00"; +export const ATRZ_REVIEWER_1_CD = "ATRZ01"; +export const ATRZ_REVIEWER_2_CD = "ATRZ02"; +export const ATRZ_APPROVER_CD = "ATRZ03"; +export const ATRZ_FINAL_CD = "ATRZ99"; -/** 검토자 최상단 · 최종승인자 최하단 순서 검증 */ +export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] { + return rows.filter((row) => !row.isDeleted); +} + +/** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */ export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null { - const reviewerIndices = rows - .map((row, index) => (row.atrzSeComCd === ATRZ_REVIEWER_CD ? index : -1)) - .filter((index) => index >= 0); + const visibleRows = getVisibleRows(rows); - if (reviewerIndices.length > 0) { - const lastReviewerIndex = Math.max(...reviewerIndices); - const hasNonReviewerAbove = rows - .slice(0, lastReviewerIndex) - .some((row) => row.atrzSeComCd && row.atrzSeComCd !== ATRZ_REVIEWER_CD); - - if (hasNonReviewerAbove) { - return "검토자는 결재선 최상단에만 배치할 수 있습니다."; - } + const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length; + if (receiverCount > 1) { + return "접수자는 한 명만 등록할 수 있습니다."; } - const finalIndices = rows - .map((row, index) => (row.atrzSeComCd === ATRZ_FINAL_CD ? index : -1)) - .filter((index) => index >= 0); + const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length; + if (finalCount > 1) { + return "최종승인자는 한 명만 등록할 수 있습니다."; + } - if (finalIndices.length > 0) { - const firstFinalIndex = Math.min(...finalIndices); - const hasNonFinalBelow = rows - .slice(firstFinalIndex + 1) - .some((row) => row.atrzSeComCd && row.atrzSeComCd !== ATRZ_FINAL_CD); + if (receiverCount === 1 && visibleRows[0]?.atrzSeComCd !== ATRZ_RECEIVER_CD) { + return "접수자는 결재선 첫 번째에 배치해야 합니다."; + } - if (hasNonFinalBelow) { - return "최종승인자는 결재선 최하단에만 배치할 수 있습니다."; + if (finalCount === 1) { + const lastRow = visibleRows[visibleRows.length - 1]; + if (lastRow?.atrzSeComCd !== ATRZ_FINAL_CD) { + return "최종승인자는 결재선 마지막에 배치해야 합니다."; } } @@ -48,9 +48,30 @@ export function validateAplnRow(row: ElaprAplnRow): string | null { return null; } +export function validateAllRowsForSave( + rows: ElaprAplnRow[], + isEditingLocked: boolean, +): string | null { + if (isEditingLocked) { + return "편집 중인 행을 먼저 저장하거나 취소해 주세요."; + } + + const visibleRows = getVisibleRows(rows); + if (visibleRows.length === 0) { + return "저장할 결재선이 없습니다."; + } + + for (const row of visibleRows) { + const fieldError = validateAplnRow(row); + if (fieldError) return fieldError; + } + + return null; +} + export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null { const seqSet = new Set(); - for (const row of rows) { + for (const row of getVisibleRows(rows)) { if (row.atrzSeqNo == null) continue; if (seqSet.has(row.atrzSeqNo)) { return "동일 서식 내 결재순서번호가 중복됩니다."; diff --git a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts b/apps/as/src/pages/approval-line-management/model/apln-row-types.ts index b2066fc78..cabc7bb08 100644 --- a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts +++ b/apps/as/src/pages/approval-line-management/model/apln-row-types.ts @@ -5,10 +5,12 @@ export interface ElaprAplnRow extends Omit { /** React key · DnD · 편집 잠금용 — 서버 aplnId와 분리 */ rowKey: string; aplnId?: string; - /** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */ + /** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */ aprvrNm?: string; isNew?: boolean; isDirty?: boolean; + /** 소프트 삭제 — 최종 저장 시 mode:D */ + isDeleted?: boolean; /** 수정 취소용 스냅샷 */ snapshot?: ElaprAplnRow; } diff --git a/apps/as/src/pages/approval-line-management/model/mock-reviewers.ts b/apps/as/src/pages/approval-line-management/model/mock-reviewers.ts deleted file mode 100644 index 863331be6..000000000 --- a/apps/as/src/pages/approval-line-management/model/mock-reviewers.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { fakerKO as faker } from "@faker-js/faker"; - -export interface ReviewerOption { - uuid: string; - name: string; - deptNm: string; -} - -/** TBD: 검토인력 목록 API 연동 전 목 데이터 */ -export function createMockReviewers(count = 12): ReviewerOption[] { - faker.seed(42); - return Array.from({ length: count }, () => ({ - uuid: faker.string.uuid(), - name: faker.person.fullName(), - deptNm: faker.commerce.department(), - })); -} diff --git a/apps/as/src/pages/approval-line-management/model/reviewer-option.ts b/apps/as/src/pages/approval-line-management/model/reviewer-option.ts new file mode 100644 index 000000000..569646744 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/model/reviewer-option.ts @@ -0,0 +1,15 @@ +import type { OgnzUserTreeUserDto } from "@/entities/user-organization"; + +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} + +export function toReviewerOption(user: OgnzUserTreeUserDto, ognzNm: string): ReviewerOption { + return { + uuid: user.userUnqId, + name: user.userNm, + deptNm: ognzNm, + }; +} diff --git a/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx b/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx index 6f20a1db3..8b6b7aa1d 100644 --- a/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx +++ b/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx @@ -1,12 +1,10 @@ "use client"; -import { useMemo, useState } from "react"; - -import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useCallback, useEffect, useState } from "react"; +import { useOgnzUserTree } from "@/entities/user-organization"; import { Button, - DataTable, Dialog, DialogContent, DialogFooter, @@ -15,8 +13,10 @@ import { Input, } from "@common/components"; -import type { ReviewerOption } from "../model/mock-reviewers"; -import { createMockReviewers } from "../model/mock-reviewers"; +import { collectExpandedOgnzSnIds, filterOgnzUserTree } from "../lib/filter-ognz-user-tree"; +import type { ReviewerOption } from "../model/reviewer-option"; + +import { OgnzUserTree } from "./ognz-user-tree"; interface AprvrPickerDialogProps { open: boolean; @@ -24,67 +24,100 @@ interface AprvrPickerDialogProps { onSelect: (reviewer: ReviewerOption) => void; } -const columnHelper = createColumnHelper(); - function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) { const [keyword, setKeyword] = useState(""); - const reviewers = useMemo(() => createMockReviewers(), []); + const [displayTree, setDisplayTree] = useState>([]); + const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState>(new Set()); + const [selectedUserId, setSelectedUserId] = useState(); - const filtered = useMemo(() => { - const q = keyword.trim().toLowerCase(); - if (!q) return reviewers; - return reviewers.filter( - (r) => r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q), - ); - }, [keyword, reviewers]); + const { data, isPending, isError } = useOgnzUserTree({ enabled: open }); - const columns = useMemo( - () => [ - columnHelper.accessor("name", { header: "성명" }), - columnHelper.accessor("deptNm", { header: "부서" }), - columnHelper.display({ - id: "actions", - header: "선택", - cell: ({ row }) => ( - - ), - }), - ], - [onOpenChange, onSelect], - ); + const resetState = useCallback(() => { + setKeyword(""); + setSelectedUserId(undefined); + setExpandedOgnzSnIds(new Set()); + setDisplayTree(data ?? []); + }, [data]); - const table = useReactTable({ - data: filtered, - columns, - getCoreRowModel: getCoreRowModel(), - }); + useEffect(() => { + if (!open) { + setKeyword(""); + setSelectedUserId(undefined); + setExpandedOgnzSnIds(new Set()); + return; + } + + setDisplayTree(data ?? []); + }, [open, data]); + + const handleToggle = (ognzSn: number, isExpanded: boolean) => { + setExpandedOgnzSnIds((prev) => { + const next = new Set(prev); + if (isExpanded) next.add(ognzSn); + else next.delete(ognzSn); + return next; + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const filtered = filterOgnzUserTree(data ?? [], keyword); + setDisplayTree(filtered); + setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered)); + }; + + const handleReset = () => { + resetState(); + }; + + const handleSelect = (reviewer: ReviewerOption) => { + setSelectedUserId(reviewer.uuid); + onSelect(reviewer); + onOpenChange(false); + }; return ( - + 검토인력 선택 -
- setKeyword(e.target.value)} - /> - + +
+
+
+ setKeyword(e.target.value)} + /> + + +
+
+ +
+ +
+ - 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 d2112c011..3111673de 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,12 +7,7 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable" import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useAlertStore } from "@/shared/lib"; -import { - useCreateElaprApln, - useDeleteElaprApln, - useElaprAplnPage, - useUpdateElaprApln, -} from "@/entities/elapr"; +import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr"; import { Button, Skeleton, @@ -23,12 +18,18 @@ import { TableRow, } from "@common/components"; -import { assignAtrzSeqNumbers } from "../lib/assign-atrz-seq"; +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 { validateAplnRow, validateAtrzOrder } from "../lib/validators"; +import { + validateAllRowsForSave, + validateAplnRow, + validateAtrzOrder, + validateUniqueSeqNo, +} from "../lib/validators"; import type { ElaprAplnRow } from "../model/apln-row-types"; -import type { ReviewerOption } from "../model/mock-reviewers"; +import type { ReviewerOption } from "../model/reviewer-option"; import { AprvrPickerDialog } from "./aprvr-picker-dialog"; import { SortableAplnRow } from "./sortable-apln-row"; @@ -51,20 +52,19 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { sort: "atrzSeqNo,asc", }); - const createMutation = useCreateElaprApln(); - const updateMutation = useUpdateElaprApln(); - const deleteMutation = useDeleteElaprApln(); + const saveMultiMutation = useSaveMultiElaprApln(); const { rows, + visibleRows, editingRowKey, isEditingLocked, addRow, startEdit, updateRow, cancelEdit, - clearEditing, - removeRowLocal, + commitEdit, + markDeleted, reorderRows, } = useAplnRows({ elaprTmpltId, @@ -73,116 +73,63 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { const sensors = useDndSensors(); const isDisabled = !elaprTmpltId; + const serverRows = aplnQuery.data?.content ?? []; + const canSave = hasUnsavedChanges(rows, serverRows); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; - const { orderError } = reorderRows(String(active.id), String(over.id)); - if (orderError) { - alert(orderError); - } + reorderRows(String(active.id), String(over.id)); }; const handleRowChange = (row: ElaprAplnRow, patch: Partial) => { updateRow(row.rowKey, patch); }; - const handleRowSave = async (row: ElaprAplnRow) => { + const handleRowSave = (row: ElaprAplnRow) => { const fieldError = validateAplnRow(row); if (fieldError) { alert(fieldError); return; } - const rowsWithSeq = assignAtrzSeqNumbers(rows.map((r) => (r.rowKey === row.rowKey ? row : r))); - const savedRow = rowsWithSeq.find((r) => r.rowKey === row.rowKey); - if (!savedRow) return; - - const orderError = validateAtrzOrder(rowsWithSeq); - if (orderError) { - alert(orderError); - return; - } - - if (!elaprTmpltId) return; - - if (savedRow.isNew) { - await createMutation.mutateAsync({ - elaprTmpltId, - aprvrUuid: savedRow.aprvrUuid, - atrzSeComCd: savedRow.atrzSeComCd, - atrzSeqNo: savedRow.atrzSeqNo, - }); - } else if (savedRow.aplnId) { - await updateMutation.mutateAsync({ - aplnId: savedRow.aplnId, - body: { - elaprTmpltId, - aprvrUuid: savedRow.aprvrUuid, - atrzSeComCd: savedRow.atrzSeComCd, - atrzSeqNo: savedRow.atrzSeqNo, - }, - }); - } - - clearEditing(); - await aplnQuery.refetch(); + commitEdit(row.rowKey); }; const handleRowDelete = async (row: ElaprAplnRow) => { if (!(await confirm("선택한 결재선을 삭제하시겠습니까?"))) return; + markDeleted(row.rowKey); + }; - if (row.isNew) { - removeRowLocal(row.rowKey); + const handleSaveAll = async () => { + if (!elaprTmpltId) return; + + const rowsError = validateAllRowsForSave(rows, isEditingLocked); + if (rowsError) { + alert(rowsError); return; } - if (!elaprTmpltId || !row.aplnId) return; - - await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId }); - await aplnQuery.refetch(); - }; - - const handleBulkSave = async () => { - if (!elaprTmpltId || isEditingLocked) return; - - const rowsWithSeq = assignAtrzSeqNumbers(rows); - const orderError = validateAtrzOrder(rowsWithSeq); + const orderError = validateAtrzOrder(rows); if (orderError) { alert(orderError); return; } - const dirtyRows = rowsWithSeq.filter((r) => r.isDirty || r.isNew); - - for (const row of dirtyRows) { - const fieldError = validateAplnRow(row); - if (fieldError) { - alert(fieldError); - return; - } - - if (row.isNew) { - await createMutation.mutateAsync({ - elaprTmpltId, - aprvrUuid: row.aprvrUuid, - atrzSeComCd: row.atrzSeComCd, - atrzSeqNo: row.atrzSeqNo, - }); - } else if (row.aplnId) { - await updateMutation.mutateAsync({ - aplnId: row.aplnId, - body: { - elaprTmpltId, - aprvrUuid: row.aprvrUuid, - atrzSeComCd: row.atrzSeComCd, - atrzSeqNo: row.atrzSeqNo, - }, - }); - } + const seqError = validateUniqueSeqNo(rows); + if (seqError) { + alert(seqError); + return; } + const payload = buildSaveMultiPayload(rows, elaprTmpltId, serverRows); + if (payload.length === 0) { + alert("저장할 변경 사항이 없습니다."); + return; + } + + await saveMultiMutation.mutateAsync({ body: payload, elaprTmpltId }); alert("결재선이 저장되었습니다."); await aplnQuery.refetch(); }; @@ -207,9 +154,18 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { disabled={isDisabled || isEditingLocked} onClick={addRow} > - + 행 추가 + + 결재선 추가 -
@@ -224,13 +180,22 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {

상단 목록에서 결재서식을 선택해 주세요.

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

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

) : ( + + + + + + + + + @@ -244,10 +209,10 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { r.rowKey)} + items={visibleRows.map((r) => r.rowKey)} strategy={verticalListSortingStrategy} > - {rows.map((row, index) => ( + {visibleRows.map((row, index) => ( ; + selectedUserId?: string; + onToggle: (ognzSn: number, open: boolean) => void; + onSelect: (reviewer: ReviewerOption) => void; +} + +function OgnzUserTreeNode({ + node, + depth = 0, + expandedOgnzSnIds, + selectedUserId, + onToggle, + onSelect, +}: OgnzUserTreeNodeProps) { + const hasUsers = (node.users?.length ?? 0) > 0; + const hasChildren = (node.children?.length ?? 0) > 0; + const isExpandable = hasUsers || hasChildren; + const isOpen = expandedOgnzSnIds.has(node.ognzSn); + const indent = depth * 16 + 8; + + if (!isExpandable) { + return ( +
+ {node.ognzNm} +
+ ); + } + + return ( + onToggle(node.ognzSn, open)} + className="w-full" + > +
+ + + +
+ + + {node.users.map((user) => { + const isSelected = selectedUserId === user.userUnqId; + + return ( +
onSelect(toReviewerOption(user, node.ognzNm))} + > + {user.userNm} + +
+ ); + })} + + {node.children.map((child) => ( + + ))} +
+
+ ); +} + +export { OgnzUserTreeNode }; diff --git a/apps/as/src/pages/approval-line-management/ui/ognz-user-tree.tsx b/apps/as/src/pages/approval-line-management/ui/ognz-user-tree.tsx new file mode 100644 index 000000000..226e878f5 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/ui/ognz-user-tree.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { OgnzUserTreeResponseDto } from "@/entities/user-organization"; +import { Skeleton } from "@common/components"; + +import type { ReviewerOption } from "../model/reviewer-option"; + +import { OgnzUserTreeNode } from "./ognz-user-tree-node"; + +interface OgnzUserTreeProps { + nodes: OgnzUserTreeResponseDto[]; + isPending?: boolean; + isError?: boolean; + expandedOgnzSnIds: Set; + selectedUserId?: string; + onToggle: (ognzSn: number, open: boolean) => void; + onSelect: (reviewer: ReviewerOption) => void; +} + +function OgnzUserTree({ + nodes, + isPending, + isError, + expandedOgnzSnIds, + selectedUserId, + onToggle, + onSelect, +}: OgnzUserTreeProps) { + if (isPending) { + return ( +
+ + + +
+ ); + } + + if (isError) { + return ( +

+ 조직 사용자 목록을 불러오지 못했습니다. +

+ ); + } + + if (nodes.length === 0) { + return ( +

+ 등록된 조직/사용자가 없습니다. +

+ ); + } + + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ); +} + +export { OgnzUserTree }; 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 c9fc44af6..5539d2347 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 @@ -103,7 +103,7 @@ function SortableAplnRow({ {isEditing ? ( <>