From 567baf74e62bb71d44604eb5fe3457f773396678 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 30 Sep 2025 16:48:52 +0900 Subject: (김준회) 결재 카테고리 로직 개선, 미사용 코드 제거 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/approval-template/category-service.ts | 271 +++++++++++ lib/approval-template/category-validations.ts | 47 ++ .../editor/approval-template-editor.tsx | 70 ++- lib/approval-template/service.ts | 47 +- .../approval-template-table-toolbar-actions.tsx | 14 + .../table/approval-template-table.tsx | 9 - .../table/category-management-dialog.tsx | 515 +++++++++++++++++++++ .../table/create-approval-template-sheet.tsx | 55 ++- .../table/update-approval-template-sheet.tsx | 23 - 9 files changed, 979 insertions(+), 72 deletions(-) create mode 100644 lib/approval-template/category-service.ts create mode 100644 lib/approval-template/category-validations.ts create mode 100644 lib/approval-template/table/category-management-dialog.tsx delete mode 100644 lib/approval-template/table/update-approval-template-sheet.tsx (limited to 'lib/approval-template') diff --git a/lib/approval-template/category-service.ts b/lib/approval-template/category-service.ts new file mode 100644 index 00000000..8f6f93c8 --- /dev/null +++ b/lib/approval-template/category-service.ts @@ -0,0 +1,271 @@ +'use server'; + +import db from '@/db/db'; +import { revalidateI18nPaths } from '@/lib/revalidate'; +import { + and, + asc, + count, + desc, + eq, + ilike, + or, +} from 'drizzle-orm'; + +import { approvalTemplateCategories, approvalTemplates } from '@/db/schema/knox/approvals'; + +// --------------------------------------------- +// Types +// --------------------------------------------- + +export type ApprovalTemplateCategory = typeof approvalTemplateCategories.$inferSelect; + +// --------------------------------------------- +// Revalidation helpers +// --------------------------------------------- +async function revalidateApprovalTemplateCategoriesPaths() { + await revalidateI18nPaths('/evcp/approval/template'); +} + +// --------------------------------------------- +// List & read helpers +// --------------------------------------------- + +interface ListInput { + page: number; + perPage: number; + search?: string; + filters?: Record[]; + joinOperator?: 'and' | 'or'; + sort?: Array<{ id: string; desc: boolean }>; +} + +export async function getApprovalTemplateCategoryList(input: ListInput) { + const offset = (input.page - 1) * input.perPage; + + /* ------------------------------------------------------------------ + * WHERE 절 구성 + * ----------------------------------------------------------------*/ + const advancedWhere = input.filters ? and( + ...input.filters.map(filter => { + // 간단한 필터링 로직 - 실제로는 filterColumns 유틸리티 사용 가능 + return eq(approvalTemplateCategories.isActive, true); + }) + ) : undefined; + + // 전역 검색 (name, description) + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(approvalTemplateCategories.name, s), + ilike(approvalTemplateCategories.description, s), + ); + } + + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + const where = + conditions.length === 0 + ? undefined + : conditions.length === 1 + ? conditions[0] + : and(...conditions); + + /* ------------------------------------------------------------------ + * ORDER BY 절 구성 + * ----------------------------------------------------------------*/ + let orderBy; + try { + orderBy = input.sort && input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== 'string') return null; + if (!(item.id in approvalTemplateCategories)) return null; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const col = approvalTemplateCategories[item.id]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude => v !== null) + : [asc(approvalTemplateCategories.sortOrder), asc(approvalTemplateCategories.name)]; + } catch { + orderBy = [asc(approvalTemplateCategories.sortOrder), asc(approvalTemplateCategories.name)]; + } + + /* ------------------------------------------------------------------ + * 데이터 조회 + * ----------------------------------------------------------------*/ + const data = await db + .select() + .from(approvalTemplateCategories) + .where(where) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + const totalResult = await db + .select({ count: count() }) + .from(approvalTemplateCategories) + .where(where); + + const total = totalResult[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + }; +} + +// ---------------------------------------------------- +// Get single category +// ---------------------------------------------------- +export async function getApprovalTemplateCategory(id: string): Promise { + const [category] = await db + .select() + .from(approvalTemplateCategories) + .where(eq(approvalTemplateCategories.id, id)) + .limit(1); + + return category || null; +} + +// ---------------------------------------------------- +// Create category +// ---------------------------------------------------- +interface CreateInput { + name: string; + description?: string; + sortOrder?: number; + createdBy: number; +} + +export async function createApprovalTemplateCategory(data: CreateInput): Promise { + // 중복 이름 체크 + const existing = await db + .select({ id: approvalTemplateCategories.id }) + .from(approvalTemplateCategories) + .where(and( + eq(approvalTemplateCategories.name, data.name), + eq(approvalTemplateCategories.isActive, true) + )) + .limit(1); + + if (existing.length > 0) { + throw new Error('이미 존재하는 카테고리 이름입니다.'); + } + + const [newCategory] = await db + .insert(approvalTemplateCategories) + .values({ + name: data.name, + description: data.description, + sortOrder: data.sortOrder ?? 0, + createdBy: data.createdBy, + updatedBy: data.createdBy, + }) + .returning(); + + await revalidateApprovalTemplateCategoriesPaths(); + return newCategory; +} + +// ---------------------------------------------------- +// Update category +// ---------------------------------------------------- +interface UpdateInput { + name?: string; + description?: string; + isActive?: boolean; + sortOrder?: number; + updatedBy: number; +} + +export async function updateApprovalTemplateCategory(id: string, data: UpdateInput): Promise { + const existing = await getApprovalTemplateCategory(id); + if (!existing) throw new Error('카테고리를 찾을 수 없습니다.'); + + // 이름 변경 시 중복 체크 + if (data.name && data.name !== existing.name) { + const existingName = await db + .select({ id: approvalTemplateCategories.id }) + .from(approvalTemplateCategories) + .where(and( + eq(approvalTemplateCategories.name, data.name), + eq(approvalTemplateCategories.isActive, true) + )) + .limit(1); + + if (existingName.length > 0) { + throw new Error('이미 존재하는 카테고리 이름입니다.'); + } + } + + await db + .update(approvalTemplateCategories) + .set({ + name: data.name ?? existing.name, + description: data.description === undefined ? existing.description : data.description, + isActive: data.isActive ?? existing.isActive, + sortOrder: data.sortOrder ?? existing.sortOrder, + updatedAt: new Date(), + updatedBy: data.updatedBy, + }) + .where(eq(approvalTemplateCategories.id, id)); + + const result = await getApprovalTemplateCategory(id); + if (!result) throw new Error('업데이트된 카테고리를 조회할 수 없습니다.'); + await revalidateApprovalTemplateCategoriesPaths(); + return result; +} + +// ---------------------------------------------------- +// Delete category (soft delete - 비활성화) +// ---------------------------------------------------- +export async function deleteApprovalTemplateCategory(id: string, updatedBy: number): Promise<{ success: boolean; error?: string }> { + try { + const existing = await getApprovalTemplateCategory(id); + if (!existing) return { success: false, error: '카테고리를 찾을 수 없습니다.' }; + + // 카테고리가 사용 중인지 확인 (approvalTemplates에서 참조 확인) + const usageCount = await db + .select({ count: count() }) + .from(approvalTemplates) + .where(eq(approvalTemplates.category, existing.name)); + + if (usageCount[0]?.count > 0) { + return { success: false, error: '사용 중인 카테고리는 삭제할 수 없습니다.' }; + } + + await db + .update(approvalTemplateCategories) + .set({ + isActive: false, + updatedAt: new Date(), + updatedBy, + }) + .where(eq(approvalTemplateCategories.id, id)); + + await revalidateApprovalTemplateCategoriesPaths(); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '삭제에 실패했습니다.', + }; + } +} + +// ---------------------------------------------------- +// Get all active categories (드롭다운용) +// ---------------------------------------------------- +export async function getActiveApprovalTemplateCategories(): Promise { + return await db + .select() + .from(approvalTemplateCategories) + .where(eq(approvalTemplateCategories.isActive, true)) + .orderBy(asc(approvalTemplateCategories.sortOrder), asc(approvalTemplateCategories.name)); +} diff --git a/lib/approval-template/category-validations.ts b/lib/approval-template/category-validations.ts new file mode 100644 index 00000000..a613fd94 --- /dev/null +++ b/lib/approval-template/category-validations.ts @@ -0,0 +1,47 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from 'nuqs/server'; +import { z } from 'zod'; + +import { getFiltersStateParser, getSortingStateParser } from '@/lib/parsers'; +import { approvalTemplateCategories } from '@/db/schema/knox/approvals'; + +// 검색 파라미터 캐시 +export const SearchParamsApprovalTemplateCategoryCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(['advancedTable', 'floatingBar'])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: 'sortOrder', desc: false }, + { id: 'name', desc: false }, + ]), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(['and', 'or']).withDefault('and'), + search: parseAsString.withDefault(''), +}); + +export type GetApprovalTemplateCategorySchema = Awaited< + ReturnType +>; + +// 카테고리 생성 스키마 +export const createApprovalTemplateCategorySchema = z.object({ + name: z.string().min(1, '카테고리 이름을 입력해주세요.').max(100, '카테고리 이름은 100자 이내로 입력해주세요.'), + description: z.string().max(500, '설명은 500자 이내로 입력해주세요.').optional(), + sortOrder: z.number().int().min(0).default(0), +}); + +// 카테고리 업데이트 스키마 +export const updateApprovalTemplateCategorySchema = z.object({ + name: z.string().min(1, '카테고리 이름을 입력해주세요.').max(100, '카테고리 이름은 100자 이내로 입력해주세요.').optional(), + description: z.string().max(500, '설명은 500자 이내로 입력해주세요.').optional(), + isActive: z.boolean().optional(), + sortOrder: z.number().int().min(0).optional(), +}); + +export type CreateApprovalTemplateCategorySchema = z.infer; +export type UpdateApprovalTemplateCategorySchema = z.infer; diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx index f23ac4bd..2c4ef65e 100644 --- a/lib/approval-template/editor/approval-template-editor.tsx +++ b/lib/approval-template/editor/approval-template-editor.tsx @@ -19,18 +19,21 @@ import { getApprovalLineOptionsAction } from "@/lib/approval-line/service" import { type ApprovalTemplate } from "@/lib/approval-template/service" import { type Editor } from "@tiptap/react" import { updateApprovalTemplateAction } from "@/lib/approval-template/service" +import { getActiveApprovalTemplateCategories, type ApprovalTemplateCategory } from "@/lib/approval-template/category-service" import { useSession } from "next-auth/react" +import { useRouter, usePathname } from "next/navigation" interface ApprovalTemplateEditorProps { templateId: string initialTemplate: ApprovalTemplate staticVariables?: Array<{ variableName: string }> approvalLineOptions: Array<{ id: string; name: string }> - approvalLineCategories: string[] } -export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVariables = [], approvalLineOptions, approvalLineCategories }: ApprovalTemplateEditorProps) { +export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVariables = [], approvalLineOptions }: ApprovalTemplateEditorProps) { const { data: session } = useSession() + const router = useRouter() + const pathname = usePathname() const [rte, setRte] = React.useState(null) const [template, setTemplate] = React.useState(initialTemplate) const [isSaving, startSaving] = React.useTransition() @@ -56,11 +59,43 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari approvalLineId: (template as { approvalLineId?: string | null }).approvalLineId ?? "", }) - const [category, setCategory] = React.useState(form.category ?? (approvalLineCategories[0] ?? "")) + const [categories, setCategories] = React.useState([]) + const [isLoadingCategories, setIsLoadingCategories] = React.useState(false) + const [category, setCategory] = React.useState(form.category ?? "") + const [isInitialLoad, setIsInitialLoad] = React.useState(true) // eslint-disable-next-line @typescript-eslint/no-explicit-any const [lineOptions, setLineOptions] = React.useState(approvalLineOptions as Array<{ id: string; name: string; aplns?: any[]; category?: string | null }>) const [isLoadingLines, setIsLoadingLines] = React.useState(false) + // 카테고리 목록 로드 (초기 한 번만) + React.useEffect(() => { + let active = true + const loadCategories = async () => { + setIsLoadingCategories(true) + try { + const data = await getActiveApprovalTemplateCategories() + if (active) { + setCategories(data) + // 초기 로드 시에만 기본 카테고리 설정 (템플릿의 카테고리가 없고 카테고리가 선택되지 않은 경우) + if (isInitialLoad && !category && data.length > 0) { + const defaultCategory = form.category || data[0].name + setCategory(defaultCategory) + } + setIsInitialLoad(false) + } + } catch (error) { + console.error('카테고리 로드 실패:', error) + } finally { + if (active) setIsLoadingCategories(false) + } + } + loadCategories() + return () => { + active = false + } + }, []) // 빈 의존성 배열로 초기 한 번만 실행 + + // 결재선 옵션 로드 React.useEffect(() => { let active = true const run = async () => { @@ -109,6 +144,12 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari setTemplate(data) toast.success("저장되었습니다") + + // 저장 후 목록 페이지로 이동 (back-button.tsx 로직 참고) + const segments = pathname.split('/').filter(Boolean) + const newSegments = segments.slice(0, -1) // 마지막 세그먼트(ID) 제거 + const targetPath = newSegments.length > 0 ? `/${newSegments.join('/')}` : '/' + router.push(targetPath) }) } @@ -161,18 +202,25 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
diff --git a/lib/approval-template/service.ts b/lib/approval-template/service.ts index cb687c4f..4b92b817 100644 --- a/lib/approval-template/service.ts +++ b/lib/approval-template/service.ts @@ -19,6 +19,7 @@ import { } from '@/db/schema/knox/approvals'; import { filterColumns } from '@/lib/filter-columns'; +import { GetApprovalTemplateSchema } from './validations'; // --------------------------------------------- // Types @@ -45,16 +46,7 @@ export interface ApprovalTemplateWithVariables extends ApprovalTemplate { // List & read helpers // --------------------------------------------- -interface ListInput { - page: number; - perPage: number; - search?: string; - filters?: Record[]; - joinOperator?: 'and' | 'or'; - sort?: Array<{ id: string; desc: boolean }>; -} - -export async function getApprovalTemplateList(input: ListInput) { +export async function getApprovalTemplateList(input: GetApprovalTemplateSchema) { const offset = (input.page - 1) * input.perPage; /* ------------------------------------------------------------------ @@ -79,15 +71,17 @@ export async function getApprovalTemplateList(input: ListInput) { } const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); if (globalWhere) conditions.push(globalWhere); - const where = - conditions.length === 0 - ? undefined - : conditions.length === 1 - ? conditions[0] - : and(...conditions); + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere; /* ------------------------------------------------------------------ * ORDER BY 절 구성 @@ -97,16 +91,14 @@ export async function getApprovalTemplateList(input: ListInput) { orderBy = input.sort && input.sort.length > 0 ? input.sort .map((item) => { - if (!item || !item.id || typeof item.id !== 'string') return null; - if (!(item.id in approvalTemplates)) return null; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const col = approvalTemplates[item.id]; + if (!item || !item.id || typeof item.id !== "string" || !(item.id in approvalTemplates)) return null; + const col = approvalTemplates[item.id as keyof typeof approvalTemplates]; return item.desc ? desc(col) : asc(col); }) .filter((v): v is Exclude => v !== null) : [desc(approvalTemplates.updatedAt)]; - } catch { + } catch (orderErr) { + console.error("Error building order by:", orderErr); orderBy = [desc(approvalTemplates.updatedAt)]; } @@ -313,9 +305,14 @@ export async function duplicateApprovalTemplate( variables: existing.variables?.map((v) => ({ variableName: v.variableName, variableType: v.variableType, - defaultValue: v.defaultValue, - description: v.description, - })), + defaultValue: v.defaultValue ?? undefined, + description: v.description ?? undefined, + })) as Array<{ + variableName: string; + variableType: string; + defaultValue?: string; + description?: string; + }>, }) return { success: true, data: duplicated } diff --git a/lib/approval-template/table/approval-template-table-toolbar-actions.tsx b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx index 08aba97a..62754cc1 100644 --- a/lib/approval-template/table/approval-template-table-toolbar-actions.tsx +++ b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button" import { type ApprovalTemplate } from "@/lib/approval-template/service" import { toast } from "sonner" import { DeleteApprovalTemplateDialog } from "./delete-approval-template-dialog" +import { CategoryManagementDialog } from "./category-management-dialog" interface ApprovalTemplateTableToolbarActionsProps { table: Table @@ -19,6 +20,7 @@ export function ApprovalTemplateTableToolbarActions({ onCreateTemplate, }: ApprovalTemplateTableToolbarActionsProps) { const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + const [showCategoryDialog, setShowCategoryDialog] = React.useState(false) const selectedRows = table.getFilteredSelectedRowModel().rows const selectedTemplates = selectedRows.map((row) => row.original) @@ -72,6 +74,18 @@ export function ApprovalTemplateTableToolbarActions({ return (
+ {/* 카테고리 관리 버튼 */} + + + {/* 새 템플릿 버튼 */} + + {/* 새 템플릿 버튼 */} + + ) : null + + return ( + <> + {trigger} + + + + 결재 템플릿 카테고리 관리 + + 결재 템플릿의 카테고리를 관리합니다. 카테고리는 부서별로 분류하여 사용하세요. + + + +
+ {/* 생성 폼 */} + {showCreateForm && ( +
+

새 카테고리 추가

+
+ +
+ ( + + 카테고리 이름 * + + + + + + )} + /> + ( + + 정렬 순서 + + field.onChange(parseInt(e.target.value) || 0)} + /> + + + + )} + /> +
+ ( + + 설명 + +