[as] feat: 결재선관리 페이지 작업

This commit is contained in:
2026-05-21 17:38:50 +09:00
parent 0ef375310f
commit df4208d4e1
23 changed files with 742 additions and 242 deletions
@@ -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<ElaprApln> {
return response.data;
}
async function create(body: ElaprAplnSaveRequest): Promise<ElaprApln> {
const response = await client.post<ElaprApln>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body);
async function saveMulti(body: ElaprAplnSaveRequest[]): Promise<ElaprApln[]> {
const response = await client.post<ElaprApln[]>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body);
return response.data;
}
@@ -39,10 +40,15 @@ async function remove(aplnId: string): Promise<void> {
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
}
async function reorder(body: ElaprAplnReorderRequest): Promise<void> {
await client.put(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/reorder`, body);
}
export const elaprAplnService = {
findPage,
findById,
create,
saveMulti,
update,
remove,
reorder,
} satisfies ElaprAplnService;
+27 -2
View File
@@ -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 }],
});
},
});
}
+12 -7
View File
@@ -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,
@@ -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<PageResponse<ElaprApln>>;
findById(aplnId: string): Promise<ElaprApln>;
create(body: ElaprAplnSaveRequest): Promise<ElaprApln>;
saveMulti(body: ElaprAplnSaveRequest[]): Promise<ElaprApln[]>;
update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln>;
remove(aplnId: string): Promise<void>;
reorder(body: ElaprAplnReorderRequest): Promise<void>;
}
@@ -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,
});
}
@@ -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<OgnzUserTreeResponseDto[]> {
const response = await client.get<OgnzUserTreeResponseDto[]>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`,
);
return response.data;
}
export const userOgnzService = {
findOgnzUserTree,
} satisfies UserOgnzService;
@@ -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";
@@ -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<OgnzUserTreeResponseDto[]>;
}
@@ -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;
}
@@ -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<number> {
const ids = new Set<number>();
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;
}
@@ -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;
}
@@ -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,
};
}
@@ -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<number>();
for (const row of rows) {
for (const row of getVisibleRows(rows)) {
if (row.atrzSeqNo == null) continue;
if (seqSet.has(row.atrzSeqNo)) {
return "동일 서식 내 결재순서번호가 중복됩니다.";
@@ -5,10 +5,12 @@ export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
/** React key · DnD · 편집 잠금용 — 서버 aplnId와 분리 */
rowKey: string;
aplnId?: string;
/** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */
/** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */
aprvrNm?: string;
isNew?: boolean;
isDirty?: boolean;
/** 소프트 삭제 — 최종 저장 시 mode:D */
isDeleted?: boolean;
/** 수정 취소용 스냅샷 */
snapshot?: ElaprAplnRow;
}
@@ -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(),
}));
}
@@ -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,
};
}
@@ -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<ReviewerOption>();
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
const [keyword, setKeyword] = useState("");
const reviewers = useMemo(() => createMockReviewers(), []);
const [displayTree, setDisplayTree] = useState<ReturnType<typeof filterOgnzUserTree>>([]);
const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState<Set<number>>(new Set());
const [selectedUserId, setSelectedUserId] = useState<string>();
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 }) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
onSelect(row.original);
onOpenChange(false);
}}
>
선택
</Button>
),
}),
],
[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<HTMLFormElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-[30rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검토인력 선택</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<DataTable table={table} variant="line" />
<div className="flex flex-col gap-4">
<div className="card card-border !m-0 w-full">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<Input
className="h-[2.5rem] !text-[1.3rem]"
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button type="submit" size="sm">
조회
</Button>
<Button type="button" size="sm" color="white" onClick={handleReset}>
초기화
</Button>
</form>
</div>
<div className="h-[36rem] overflow-auto">
<OgnzUserTree
nodes={displayTree}
isPending={isPending}
isError={isError}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
onToggle={handleToggle}
onSelect={handleSelect}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
@@ -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<ElaprAplnRow>) => {
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}
>
+ 행 추가
+ 결재선 추가
</Button>
<Button type="button" disabled={isDisabled || isEditingLocked} onClick={handleBulkSave}>
<Button
type="button"
disabled={
isDisabled ||
isEditingLocked ||
!canSave ||
saveMultiMutation.isPending
}
onClick={handleSaveAll}
>
결재선 저장
</Button>
</div>
@@ -224,13 +180,22 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
<p className="py-8 text-center text-lg text-muted-foreground">
상단 목록에서 결재서식을 선택해 주세요.
</p>
) : rows.length === 0 ? (
) : visibleRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 결재선이 없습니다. [행 추가]로 결재자를 등록하세요.
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table className="w-full">
<colgroup>
<col width="10%" />
<col width="30%" />
<col width="10%" />
<col width="10%" />
<col width="15%" />
<col width="15%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
@@ -244,10 +209,10 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
</TableHeader>
<TableBody>
<SortableContext
items={rows.map((r) => r.rowKey)}
items={visibleRows.map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
{rows.map((row, index) => (
{visibleRows.map((row, index) => (
<SortableAplnRow
key={row.rowKey}
row={row}
@@ -0,0 +1,116 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
import {
Button,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@common/components";
import type { ReviewerOption } from "../model/reviewer-option";
import { toReviewerOption } from "../model/reviewer-option";
interface OgnzUserTreeNodeProps {
node: OgnzUserTreeResponseDto;
depth?: number;
expandedOgnzSnIds: Set<number>;
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 (
<div style={{ paddingInlineStart: indent }} className="py-1">
<span className="text-[1.4rem] font-medium text-label">{node.ognzNm}</span>
</div>
);
}
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => onToggle(node.ognzSn, open)}
className="w-full"
>
<div style={{ paddingInlineStart: indent }}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 rounded-sm p-2 text-left hover:bg-accent"
>
<ChevronRight
className={cn(
"size-5 shrink-0 text-label transition-transform",
isOpen && "rotate-90",
)}
/>
<span className="text-[1.4rem] font-medium text-label">{node.ognzNm}</span>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
{node.users.map((user) => {
const isSelected = selectedUserId === user.userUnqId;
return (
<div
key={user.userUnqId}
style={{ paddingInlineStart: indent + 24 }}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "bg-accent",
)}
onClick={() => onSelect(toReviewerOption(user, node.ognzNm))}
>
<span className="text-[1.4rem] text-label">{user.userNm}</span>
<Button
type="button"
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelect(toReviewerOption(user, node.ognzNm));
}}
>
선택
</Button>
</div>
);
})}
{node.children.map((child) => (
<OgnzUserTreeNode
key={child.ognzSn}
node={child}
depth={depth + 1}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
export { OgnzUserTreeNode };
@@ -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<number>;
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 (
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-3/4" />
</div>
);
}
if (isError) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
조직 사용자 목록을 불러오지 못했습니다.
</p>
);
}
if (nodes.length === 0) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 조직/사용자가 없습니다.
</p>
);
}
return (
<div className="flex flex-col">
{nodes.map((node) => (
<OgnzUserTreeNode
key={node.ognzSn}
node={node}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
);
}
export { OgnzUserTree };
@@ -103,7 +103,7 @@ function SortableAplnRow({
{isEditing ? (
<>
<Button type="button" size="sm" onClick={onSave}>
저장
확인
</Button>
<Button type="button" size="sm" color="white" onClick={onCancel}>
취소
+1
View File
@@ -20,6 +20,7 @@ export const ENDPOINT = {
CODE: "/cms-service/api/v1/code/cache",
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
},
};
@@ -5,6 +5,7 @@ export * from "./button-group";
export * from "./calendar";
export * from "./carousel";
export * from "./checkbox";
export * from "./collapsible";
export * from "./combobox";
export * from "./data-table";
export * from "./date-picker";