diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-30 16:48:52 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-30 16:48:52 +0900 |
| commit | 567baf74e62bb71d44604eb5fe3457f773396678 (patch) | |
| tree | d917f36c85916e500fb6b3043841dd346235c07f | |
| parent | 0cd76046aa7c5426d42740f9acb06c42e4d7e686 (diff) | |
(김준회) 결재 카테고리 로직 개선, 미사용 코드 제거
13 files changed, 1065 insertions, 80 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx index 136b09eb..4ce13b42 100644 --- a/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/(system)/approval/template/[id]/page.tsx @@ -3,7 +3,7 @@ import { type Metadata } from "next" import { notFound } from "next/navigation" import { getApprovalTemplate } from "@/lib/approval-template/service" -import { getApprovalLineOptions, getApprovalLineCategories } from "@/lib/approval-line/service" +import { getApprovalLineOptions } from "@/lib/approval-line/service" import { ApprovalTemplateEditor } from "@/lib/approval-template/editor/approval-template-editor" import { variables as configVariables } from "./config" @@ -31,10 +31,9 @@ export async function generateMetadata({ params }: ApprovalTemplateDetailPagePro export default async function ApprovalTemplateDetailPage({ params }: ApprovalTemplateDetailPageProps) { const { id } = await params - const [template, approvalLineOptions, approvalLineCategories] = await Promise.all([ + const [template, approvalLineOptions] = await Promise.all([ getApprovalTemplate(id), getApprovalLineOptions(), - getApprovalLineCategories(), ]) if (!template) { @@ -49,7 +48,6 @@ export default async function ApprovalTemplateDetailPage({ params }: ApprovalTem initialTemplate={template} staticVariables={configVariables as unknown as Array<{ variableName: string }>} approvalLineOptions={approvalLineOptions} - approvalLineCategories={approvalLineCategories} /> )} </div> diff --git a/db/schema/knox/approvals.ts b/db/schema/knox/approvals.ts index 5cb3519d..622f0be2 100644 --- a/db/schema/knox/approvals.ts +++ b/db/schema/knox/approvals.ts @@ -130,3 +130,19 @@ export const approvalTemplateVariables = knoxSchema.table('approval_template_var updatedAt: timestamp().defaultNow().notNull(), }); +// 결재 템플릿 카테고리 관리 +export const approvalTemplateCategories = knoxSchema.table('approval_template_categories', { + id: uuid().primaryKey().defaultRandom(), // 카테고리 아이디 UUID + name: text().notNull(), // 카테고리 이름 + description: text(), // 카테고리 설명 + isActive: boolean().default(true).notNull(), // 활성화 여부 + sortOrder: integer().default(0).notNull(), // 정렬 순서 + createdBy: integer() // 카테고리 생성자 - eVCP 유저 아이디 기반 참조 + .notNull() + .references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp().defaultNow().notNull(), + updatedAt: timestamp().defaultNow().notNull(), + updatedBy: integer() // 카테고리 수정자 - eVCP 유저 아이디 기반 참조 + .notNull() + .references(() => users.id, { onDelete: 'set null' }), +});
\ No newline at end of file diff --git a/lib/approval-line/table/approval-line-table-toolbar-actions.tsx b/lib/approval-line/table/approval-line-table-toolbar-actions.tsx index 6b6600fe..53798e5f 100644 --- a/lib/approval-line/table/approval-line-table-toolbar-actions.tsx +++ b/lib/approval-line/table/approval-line-table-toolbar-actions.tsx @@ -5,8 +5,9 @@ import { type Table } from "@tanstack/react-table" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { DataTableViewOptions } from "@/components/data-table/data-table-view-options" -import { Plus, Download, Upload } from "lucide-react" +import { Plus, Download, Upload, Settings } from "lucide-react" import { type ApprovalLine } from "../service" +import { CategoryManagementDialog } from "@/lib/approval-template/table/category-management-dialog" interface ApprovalLineTableToolbarActionsProps { table: Table<ApprovalLine> @@ -17,6 +18,7 @@ export function ApprovalLineTableToolbarActions({ table, onCreateLine, }: ApprovalLineTableToolbarActionsProps) { + const [showCategoryDialog, setShowCategoryDialog] = React.useState(false) const isFiltered = table.getState().columnFilters.length > 0 return ( @@ -41,6 +43,22 @@ export function ApprovalLineTableToolbarActions({ )} </div> <div className="flex items-center space-x-2"> + {/* 카테고리 관리 버튼 */} + <CategoryManagementDialog + open={showCategoryDialog} + onOpenChange={setShowCategoryDialog} + showTrigger={false} + /> + + <Button + variant="outline" + size="sm" + onClick={() => setShowCategoryDialog(true)} + > + <Settings className="mr-2 h-4 w-4" /> + 카테고리 관리 + </Button> + <Button variant="outline" size="sm" diff --git a/lib/approval-line/table/create-approval-line-sheet.tsx b/lib/approval-line/table/create-approval-line-sheet.tsx index c19d11ab..b7878f71 100644 --- a/lib/approval-line/table/create-approval-line-sheet.tsx +++ b/lib/approval-line/table/create-approval-line-sheet.tsx @@ -30,6 +30,7 @@ import { type ApprovalLineFormData, ApprovalLineSchema } from "../validations" import { ApprovalLineSelector } from "@/components/knox/approval/ApprovalLineSelector" import { OrganizationManagerSelector, type OrganizationManagerItem } from "@/components/common/organization/organization-manager-selector" import { useSession } from "next-auth/react" +import { getActiveApprovalTemplateCategories, type ApprovalTemplateCategory } from "@/lib/approval-template/category-service" interface CreateApprovalLineSheetProps { open: boolean @@ -39,6 +40,8 @@ interface CreateApprovalLineSheetProps { export function CreateApprovalLineSheet({ open, onOpenChange }: CreateApprovalLineSheetProps) { const { data: session } = useSession(); const [isSubmitting, setIsSubmitting] = React.useState(false); + const [categories, setCategories] = React.useState<ApprovalTemplateCategory[]>([]); + const [isLoadingCategories, setIsLoadingCategories] = React.useState(false); // 고유 ID 생성 함수 (조직 관리자 추가 시 사용) const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; @@ -68,6 +71,30 @@ export function CreateApprovalLineSheet({ open, onOpenChange }: CreateApprovalLi const aplns = form.watch("aplns"); + // 카테고리 목록 로드 + React.useEffect(() => { + let active = true; + const loadCategories = async () => { + if (!open) return; + + setIsLoadingCategories(true); + try { + const data = await getActiveApprovalTemplateCategories(); + if (active) { + setCategories(data); + } + } catch (error) { + console.error('카테고리 로드 실패:', error); + } finally { + if (active) setIsLoadingCategories(false); + } + }; + loadCategories(); + return () => { + active = false; + }; + }, [open]); + // 조직 관리자 추가 (공용 선택기 외 보조 입력 경로) const addOrganizationManagers = (managers: OrganizationManagerItem[]) => { const next = [...aplns]; @@ -157,9 +184,28 @@ export function CreateApprovalLineSheet({ open, onOpenChange }: CreateApprovalLi render={({ field }) => ( <FormItem> <FormLabel>카테고리</FormLabel> - <FormControl> - <Input placeholder="카테고리를 입력하세요" {...field} /> - </FormControl> + <Select + value={field.value || "none"} + onValueChange={(value) => field.onChange(value === "none" ? "" : value)} + disabled={isLoadingCategories} + > + <SelectTrigger> + <SelectValue placeholder={isLoadingCategories ? "카테고리 로드 중..." : "카테고리를 선택하세요"} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">선택 안함</SelectItem> + {categories + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((category) => ( + <SelectItem key={`category-${category.id}`} value={category.name}> + {category.name} + {category.description && ( + <span className="text-muted-foreground ml-2">({category.description})</span> + )} + </SelectItem> + ))} + </SelectContent> + </Select> <FormMessage /> </FormItem> )} 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<string, unknown>[]; + 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<typeof v, null> => 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<ApprovalTemplateCategory | null> { + 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<ApprovalTemplateCategory> { + // 중복 이름 체크 + 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<ApprovalTemplateCategory> { + 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<ApprovalTemplateCategory[]> { + 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<typeof approvalTemplateCategories>().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<typeof SearchParamsApprovalTemplateCategoryCache.parse> +>; + +// 카테고리 생성 스키마 +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<typeof createApprovalTemplateCategorySchema>; +export type UpdateApprovalTemplateCategorySchema = z.infer<typeof updateApprovalTemplateCategorySchema>; 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<Editor | null>(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<ApprovalTemplateCategory[]>([]) + 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 <div className="space-y-2"> <label className="text-sm font-medium">카테고리</label> <Select - value={category} - onValueChange={(value) => setCategory(value)} + value={category || "none"} + onValueChange={(value) => setCategory(value === "none" ? "" : value)} + disabled={isLoadingCategories} > <SelectTrigger> - <SelectValue placeholder="카테고리를 선택하세요" /> + <SelectValue placeholder={isLoadingCategories ? "카테고리 로드 중..." : "카테고리를 선택하세요"} /> </SelectTrigger> <SelectContent> - {approvalLineCategories.map((cat) => ( - <SelectItem key={cat} value={cat}> - {cat} - </SelectItem> - ))} + <SelectItem value="none">선택 안함</SelectItem> + {categories + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((cat) => ( + <SelectItem key={`category-${cat.id}`} value={cat.name}> + {cat.name} + {cat.description && ( + <span className="text-muted-foreground ml-2">({cat.description})</span> + )} + </SelectItem> + ))} </SelectContent> </Select> </div> 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<string, unknown>[]; - 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<typeof v, null> => 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<ApprovalTemplate> @@ -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 ( <div className="flex items-center gap-2"> + {/* 카테고리 관리 버튼 */} + <CategoryManagementDialog + open={showCategoryDialog} + onOpenChange={setShowCategoryDialog} + showTrigger={false} + /> + + {/* 새 템플릿 버튼 */} + <Button variant="outline" size="sm" onClick={() => setShowCategoryDialog(true)}> + 카테고리 관리 + </Button> + {/* 새 템플릿 버튼 */} <Button variant="default" size="sm" onClick={onCreateTemplate}> <Plus className="mr-2 size-4" aria-hidden="true" /> diff --git a/lib/approval-template/table/approval-template-table.tsx b/lib/approval-template/table/approval-template-table.tsx index d7f0e478..84d615a8 100644 --- a/lib/approval-template/table/approval-template-table.tsx +++ b/lib/approval-template/table/approval-template-table.tsx @@ -15,7 +15,6 @@ import { getApprovalTemplateList } from '../service'; import { type ApprovalTemplate } from '../service'; import { ApprovalTemplateTableToolbarActions } from './approval-template-table-toolbar-actions'; import { CreateApprovalTemplateSheet } from './create-approval-template-sheet'; -import { UpdateApprovalTemplateSheet } from './update-approval-template-sheet'; import { DuplicateApprovalTemplateSheet } from './duplicate-approval-template-sheet'; import { DeleteApprovalTemplateDialog } from './delete-approval-template-dialog'; @@ -27,7 +26,6 @@ interface ApprovalTemplateTableProps { export function ApprovalTemplateTable({ promises }: ApprovalTemplateTableProps) { const [{ data, pageCount }] = React.use(promises); - const [rowAction, setRowAction] = React.useState<DataTableRowAction<ApprovalTemplate> | null>(null); @@ -105,13 +103,6 @@ export function ApprovalTemplateTable({ promises }: ApprovalTemplateTableProps) onOpenChange={setShowCreateSheet} /> - {/* 템플릿 수정 Sheet */} - <UpdateApprovalTemplateSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - template={rowAction?.type === "update" ? rowAction.row.original : null} - /> - {/* 템플릿 복제 Sheet */} <DuplicateApprovalTemplateSheet open={rowAction?.type === "duplicate"} diff --git a/lib/approval-template/table/category-management-dialog.tsx b/lib/approval-template/table/category-management-dialog.tsx new file mode 100644 index 00000000..7f8b202d --- /dev/null +++ b/lib/approval-template/table/category-management-dialog.tsx @@ -0,0 +1,515 @@ +"use client" + +import * as React from "react" +import { Loader, Plus, Settings, Trash, Edit, Eye, EyeOff } from "lucide-react" +import { toast } from "sonner" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" + +import { type ApprovalTemplateCategory } from "../category-service" +import { + createApprovalTemplateCategory, + updateApprovalTemplateCategory, + deleteApprovalTemplateCategory, + getApprovalTemplateCategoryList, + getActiveApprovalTemplateCategories, +} from "../category-service" +import { + createApprovalTemplateCategorySchema, + updateApprovalTemplateCategorySchema, + type CreateApprovalTemplateCategorySchema, + type UpdateApprovalTemplateCategorySchema, +} from "../category-validations" + +interface CategoryManagementDialogProps extends React.ComponentPropsWithRef<typeof Dialog> { + showTrigger?: boolean + onSuccess?: () => void +} + +export function CategoryManagementDialog({ + showTrigger = true, + onSuccess, + ...props +}: CategoryManagementDialogProps) { + const [categories, setCategories] = React.useState<ApprovalTemplateCategory[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [editingCategory, setEditingCategory] = React.useState<ApprovalTemplateCategory | null>(null) + const [deletingCategory, setDeletingCategory] = React.useState<ApprovalTemplateCategory | null>(null) + const [showCreateForm, setShowCreateForm] = React.useState(false) + + // 폼 상태 + const createForm = useForm<CreateApprovalTemplateCategorySchema>({ + resolver: zodResolver(createApprovalTemplateCategorySchema), + defaultValues: { + name: "", + description: "", + sortOrder: 0, + }, + }) + + const updateForm = useForm<UpdateApprovalTemplateCategorySchema>({ + resolver: zodResolver(updateApprovalTemplateCategorySchema), + defaultValues: { + name: "", + description: "", + isActive: true, + sortOrder: 0, + }, + }) + + // 카테고리 목록 로드 + const loadCategories = React.useCallback(async () => { + setIsLoading(true) + try { + const result = await getApprovalTemplateCategoryList({ + page: 1, + perPage: 100, // 충분히 큰 수로 모든 카테고리 로드 + sort: [ + { id: 'sortOrder', desc: false }, + { id: 'name', desc: false } + ], + }) + setCategories(result.data) + } catch (error) { + toast.error("카테고리 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoading(false) + } + }, []) + + React.useEffect(() => { + if (props.open) { + loadCategories() + } + }, [props.open, loadCategories]) + + // 생성 핸들러 + const handleCreate = async (data: CreateApprovalTemplateCategorySchema) => { + try { + // 임시 사용자 ID (실제로는 세션에서 가져와야 함) + const userId = 1 // TODO: 실제 사용자 ID로 변경 + + await createApprovalTemplateCategory({ + ...data, + createdBy: userId, + }) + + toast.success("카테고리가 생성되었습니다.") + createForm.reset() + setShowCreateForm(false) + loadCategories() + onSuccess?.() + } catch (error) { + toast.error(error instanceof Error ? error.message : "카테고리 생성에 실패했습니다.") + } + } + + // 수정 핸들러 + const handleUpdate = async (data: UpdateApprovalTemplateCategorySchema) => { + if (!editingCategory) return + + try { + // 임시 사용자 ID (실제로는 세션에서 가져와야 함) + const userId = 1 // TODO: 실제 사용자 ID로 변경 + + await updateApprovalTemplateCategory(editingCategory.id, { + ...data, + updatedBy: userId, + }) + + toast.success("카테고리가 수정되었습니다.") + setEditingCategory(null) + updateForm.reset() + loadCategories() + onSuccess?.() + } catch (error) { + toast.error(error instanceof Error ? error.message : "카테고리 수정에 실패했습니다.") + } + } + + // 삭제 핸들러 + const handleDelete = async () => { + if (!deletingCategory) return + + try { + // 임시 사용자 ID (실제로는 세션에서 가져와야 함) + const userId = 1 // TODO: 실제 사용자 ID로 변경 + + const result = await deleteApprovalTemplateCategory(deletingCategory.id, userId) + if (result.error) { + toast.error(result.error) + } else { + toast.success("카테고리가 삭제되었습니다.") + loadCategories() + onSuccess?.() + } + } catch (error) { + toast.error("카테고리 삭제에 실패했습니다.") + } finally { + setDeletingCategory(null) + } + } + + // 수정 폼 열기 + const openEditForm = (category: ApprovalTemplateCategory) => { + setEditingCategory(category) + updateForm.reset({ + name: category.name, + description: category.description || "", + isActive: category.isActive, + sortOrder: category.sortOrder, + }) + } + + const trigger = showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Settings className="mr-2 size-4" aria-hidden="true" /> + 카테고리 관리 + </Button> + </DialogTrigger> + ) : null + + return ( + <> + {trigger} + <Dialog {...props}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>결재 템플릿 카테고리 관리</DialogTitle> + <DialogDescription> + 결재 템플릿의 카테고리를 관리합니다. 카테고리는 부서별로 분류하여 사용하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 생성 폼 */} + {showCreateForm && ( + <div className="border rounded-lg p-4 space-y-4"> + <h3 className="text-lg font-semibold">새 카테고리 추가</h3> + <Form {...createForm}> + <form onSubmit={createForm.handleSubmit(handleCreate)} className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={createForm.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리 이름 *</FormLabel> + <FormControl> + <Input placeholder="카테고리 이름을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={createForm.control} + name="sortOrder" + render={({ field }) => ( + <FormItem> + <FormLabel>정렬 순서</FormLabel> + <FormControl> + <Input + type="number" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + <FormField + control={createForm.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="카테고리에 대한 설명을 입력하세요" + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <div className="flex gap-2"> + <Button type="submit" size="sm"> + <Plus className="mr-2 size-4" /> + 추가 + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setShowCreateForm(false) + createForm.reset() + }} + > + 취소 + </Button> + </div> + </form> + </Form> + </div> + )} + + {/* 카테고리 목록 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">카테고리 목록</h3> + <Button + variant="outline" + size="sm" + onClick={() => setShowCreateForm(!showCreateForm)} + > + <Plus className="mr-2 size-4" /> + {showCreateForm ? "취소" : "새 카테고리"} + </Button> + </div> + + {isLoading ? ( + <div className="flex items-center justify-center py-8"> + <Loader className="size-4 animate-spin" /> + <span className="ml-2">로딩 중...</span> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead>이름</TableHead> + <TableHead>설명</TableHead> + <TableHead>정렬순서</TableHead> + <TableHead>상태</TableHead> + <TableHead className="w-[120px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {categories.length === 0 ? ( + <TableRow> + <TableCell colSpan={5} className="text-center py-8 text-muted-foreground"> + 등록된 카테고리가 없습니다. + </TableCell> + </TableRow> + ) : ( + categories.map((category) => ( + <TableRow key={category.id}> + <TableCell className="font-medium">{category.name}</TableCell> + <TableCell className="max-w-[200px] truncate"> + {category.description || "-"} + </TableCell> + <TableCell>{category.sortOrder}</TableCell> + <TableCell> + <Badge variant={category.isActive ? "default" : "secondary"}> + {category.isActive ? ( + <> + <Eye className="mr-1 size-3" /> + 활성 + </> + ) : ( + <> + <EyeOff className="mr-1 size-3" /> + 비활성 + </> + )} + </Badge> + </TableCell> + <TableCell> + <div className="flex gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => openEditForm(category)} + > + <Edit className="size-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => setDeletingCategory(category)} + > + <Trash className="size-4" /> + </Button> + </div> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + )} + </div> + + {/* 수정 폼 */} + {editingCategory && ( + <div className="border rounded-lg p-4 space-y-4"> + <h3 className="text-lg font-semibold">카테고리 수정</h3> + <Form {...updateForm}> + <form onSubmit={updateForm.handleSubmit(handleUpdate)} className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={updateForm.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리 이름 *</FormLabel> + <FormControl> + <Input placeholder="카테고리 이름을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={updateForm.control} + name="sortOrder" + render={({ field }) => ( + <FormItem> + <FormLabel>정렬 순서</FormLabel> + <FormControl> + <Input + type="number" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + <FormField + control={updateForm.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="카테고리에 대한 설명을 입력하세요" + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={updateForm.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex items-center space-x-2"> + <FormControl> + <input + type="checkbox" + checked={field.value} + onChange={field.onChange} + className="rounded" + /> + </FormControl> + <FormLabel>활성 상태</FormLabel> + <FormMessage /> + </FormItem> + )} + /> + <div className="flex gap-2"> + <Button type="submit" size="sm"> + <Edit className="mr-2 size-4" /> + 수정 + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setEditingCategory(null) + updateForm.reset() + }} + > + 취소 + </Button> + </div> + </form> + </Form> + </div> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => props.onOpenChange?.(false)}> + 닫기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 삭제 확인 다이얼로그 */} + <AlertDialog open={!!deletingCategory} onOpenChange={() => setDeletingCategory(null)}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>카테고리 삭제</AlertDialogTitle> + <AlertDialogDescription> + "{deletingCategory?.name}" 카테고리를 삭제하시겠습니까? + 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ) +} diff --git a/lib/approval-template/table/create-approval-template-sheet.tsx b/lib/approval-template/table/create-approval-template-sheet.tsx index 7e899175..b8e7eafb 100644 --- a/lib/approval-template/table/create-approval-template-sheet.tsx +++ b/lib/approval-template/table/create-approval-template-sheet.tsx @@ -20,6 +20,7 @@ import { FormDescription, } from "@/components/ui/form" import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Sheet, SheetClose, @@ -31,6 +32,7 @@ import { } from "@/components/ui/sheet" import { createApprovalTemplate } from "../service" +import { getActiveApprovalTemplateCategories, type ApprovalTemplateCategory } from "../category-service" const createSchema = z.object({ name: z.string().min(1, "이름은 필수입니다").max(100, "100자 이하"), @@ -47,6 +49,8 @@ export function CreateApprovalTemplateSheet({ ...props }: CreateApprovalTemplate const [isPending, startTransition] = React.useTransition() const router = useRouter() const { data: session } = useSession() + const [categories, setCategories] = React.useState<ApprovalTemplateCategory[]>([]) + const [isLoadingCategories, setIsLoadingCategories] = React.useState(false) const form = useForm<CreateSchema>({ resolver: zodResolver(createSchema), @@ -58,6 +62,30 @@ export function CreateApprovalTemplateSheet({ ...props }: CreateApprovalTemplate }, }) + // 카테고리 목록 로드 + React.useEffect(() => { + let active = true + const loadCategories = async () => { + if (!props.open) return + + setIsLoadingCategories(true) + try { + const data = await getActiveApprovalTemplateCategories() + if (active) { + setCategories(data) + } + } catch (error) { + console.error('카테고리 로드 실패:', error) + } finally { + if (active) setIsLoadingCategories(false) + } + } + loadCategories() + return () => { + active = false + } + }, [props.open]) + function onSubmit(values: CreateSchema) { startTransition(async () => { if (!session?.user?.id) { @@ -132,10 +160,29 @@ export function CreateApprovalTemplateSheet({ ...props }: CreateApprovalTemplate render={({ field }) => ( <FormItem> <FormLabel>카테고리 (선택)</FormLabel> - <FormControl> - <Input placeholder="카테고리" {...field} /> - </FormControl> - <FormDescription>카테고리를 입력하지 않으면 미분류로 저장됩니다.</FormDescription> + <Select + value={field.value || "none"} + onValueChange={(value) => field.onChange(value === "none" ? undefined : value)} + disabled={isLoadingCategories} + > + <SelectTrigger> + <SelectValue placeholder={isLoadingCategories ? "카테고리 로드 중..." : "카테고리를 선택하세요"} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">선택 안함</SelectItem> + {categories + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((category) => ( + <SelectItem key={category.id} value={category.name}> + {category.name} + {category.description && ( + <span className="text-muted-foreground ml-2">({category.description})</span> + )} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription>카테고리를 선택하지 않으면 미분류로 저장됩니다.</FormDescription> <FormMessage /> </FormItem> )} diff --git a/lib/approval-template/table/update-approval-template-sheet.tsx b/lib/approval-template/table/update-approval-template-sheet.tsx deleted file mode 100644 index 05f4069c..00000000 --- a/lib/approval-template/table/update-approval-template-sheet.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client" - -import * as React from "react" -import { Sheet } from "@/components/ui/sheet" -import { type ApprovalTemplate } from "@/lib/approval-template/service" - -interface UpdateApprovalTemplateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - template: ApprovalTemplate | null -} - -export function UpdateApprovalTemplateSheet({ template, ...props }: UpdateApprovalTemplateSheetProps) { - // 현재 프로젝트 요구사항 범위 내에서는 상세 편집 페이지 또는 별도 에디터가 준비되지 않았으므로 - // 업데이트 시트는 추후 구현합니다. (이메일 템플릿에서는 사용되지 않아 주석 처리되어 있었음) - // 이를 사용하려 할 경우 안내 문구만 표시합니다. - - return ( - <Sheet {...props}> - <div className="p-6 text-sm text-muted-foreground"> - 템플릿 편집 기능은 아직 구현되지 않았습니다. - </div> - </Sheet> - ) -} |
