[as] feat: 결재선관리 페이지 작업
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
취소
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user