diff options
Diffstat (limited to 'lib/docu-list-rule/document-class')
14 files changed, 2050 insertions, 0 deletions
diff --git a/lib/docu-list-rule/document-class/service.ts b/lib/docu-list-rule/document-class/service.ts new file mode 100644 index 00000000..04dfa50e --- /dev/null +++ b/lib/docu-list-rule/document-class/service.ts @@ -0,0 +1,462 @@ +"use server" + +import { revalidatePath } from "next/cache" +import db from "@/db/db" +import { documentClasses, documentClassOptions, codeGroups } from "@/db/schema/docu-list-rule" +import { eq, desc, asc, sql } from "drizzle-orm" + +// Document Class 목록 조회 (A Class, B Class 등) +export async function getDocumentClassCodeGroups(input: { + page: number + perPage: number + search?: string + sort?: Array<{ id: string; desc: boolean }> + filters?: Array<{ id: string; value: string }> + joinOperator?: "and" | "or" + flags?: string[] + classId?: string + description?: string + isActive?: string +}) { + try { + const { page, perPage, sort, search } = input + const offset = (page - 1) * perPage + + // 기본 조건 + let whereConditions = sql`${documentClasses.isActive} = true` + + // 검색 조건 + if (search) { + const searchTerm = `%${search}%` + whereConditions = sql`${whereConditions} AND ( + ${documentClasses.code} ILIKE ${searchTerm} OR + ${documentClasses.value} ILIKE ${searchTerm} OR + ${documentClasses.description} ILIKE ${searchTerm} + )` + } + + // 정렬 + let orderBy = sql`${documentClasses.createdAt} DESC` + if (sort && sort.length > 0) { + const sortField = sort[0] + const direction = sortField.desc ? sql`DESC` : sql`ASC` + + switch (sortField.id) { + case "code": + orderBy = sql`${documentClasses.code} ${direction}` + break + case "value": + orderBy = sql`${documentClasses.value} ${direction}` + break + case "description": + orderBy = sql`${documentClasses.description} ${direction}` + break + case "createdAt": + orderBy = sql`${documentClasses.createdAt} ${direction}` + break + default: + orderBy = sql`${documentClasses.createdAt} DESC` + } + } + + // 데이터 조회 + const data = await db + .select({ + id: documentClasses.id, + code: documentClasses.code, + value: documentClasses.value, + description: documentClasses.description, + isActive: documentClasses.isActive, + createdAt: documentClasses.createdAt, + updatedAt: documentClasses.updatedAt, + }) + .from(documentClasses) + .where(whereConditions) + .orderBy(orderBy) + .limit(perPage) + .offset(offset) + + // 총 개수 조회 + const [{ count: total }] = await db + .select({ count: sql`count(*)` }) + .from(documentClasses) + .where(whereConditions) + + const pageCount = Math.ceil(Number(total) / perPage) + + return { + success: true, + data, + pageCount, + } + } catch (error) { + console.error("Error fetching document classes:", error) + return { + success: false, + error: "Failed to fetch document classes", + data: [], + pageCount: 0, + } + } +} + +// Document Class 생성 +export async function createDocumentClassCodeGroup(input: { + value: string + description?: string +}) { + try { + // Value 자동 변환: "A", "AB", "A Class", "A CLASS" 등을 "A Class", "AB Class" 형태로 변환 + const formatValue = (value: string): string => { + // 공백 제거 및 대소문자 정규화 + const cleaned = value.trim().toLowerCase() + + // "class"가 포함되어 있으면 제거 + const withoutClass = cleaned.replace(/\s*class\s*/g, '') + + // 알파벳과 숫자만 추출 + const letters = withoutClass.replace(/[^a-z0-9]/g, '') + + if (letters.length === 0) { + return value.trim() // 변환할 수 없으면 원본 반환 + } + + // 첫 글자를 대문자로 변환하고 "Class" 추가 + return letters.charAt(0).toUpperCase() + letters.slice(1) + " Class" + } + + const formattedValue = formatValue(input.value) + + // 자동으로 code 생성 (예: "DOC_CLASS_001", "DOC_CLASS_002" 등) + const existingClasses = await db + .select({ code: documentClasses.code }) + .from(documentClasses) + .orderBy(desc(documentClasses.code)) + + let newCode = "DOC_CLASS_001" + if (existingClasses.length > 0) { + const lastClass = existingClasses[0] + if (lastClass.code) { + const lastNumber = parseInt(lastClass.code.replace("DOC_CLASS_", "")) || 0 + newCode = `DOC_CLASS_${String(lastNumber + 1).padStart(3, '0')}` + } + } + + // Code Group이 존재하는지 확인 + const existingCodeGroup = await db + .select({ id: codeGroups.id }) + .from(codeGroups) + .where(eq(codeGroups.groupId, 'DOC_CLASS')) + .limit(1) + + let codeGroupId: number | null = null + + if (existingCodeGroup.length === 0) { + // Code Group이 없으면 자동으로 생성 + const [newCodeGroup] = await db + .insert(codeGroups) + .values({ + groupId: 'DOC_CLASS', + description: 'Document Class', + codeFormat: 'DOC_CLASS_###', + expressions: '^DOC_CLASS_\\d{3}$', + controlType: 'Combobox', + isActive: true, + }) + .returning({ id: codeGroups.id }) + + codeGroupId = newCodeGroup.id + } else { + codeGroupId = existingCodeGroup[0].id + } + + const [newDocumentClass] = await db + .insert(documentClasses) + .values({ + code: newCode, + value: formattedValue, + description: input.description || "", + codeGroupId: codeGroupId, + isActive: true, + }) + .returning({ id: documentClasses.id }) + + revalidatePath("/evcp/docu-list-rule/document-class") + revalidatePath("/evcp/docu-list-rule/code-groups") + + return { + success: true, + data: newDocumentClass, + message: "Document Class created successfully" + } + } catch (error) { + console.error("Error creating document class:", error) + return { + success: false, + error: "Failed to create document class" + } + } +} + +// Document Class 수정 +export async function updateDocumentClassCodeGroup(input: { + id: number + value: string + description?: string +}) { + try { + // Value 자동 변환: "A", "AB", "A Class", "A CLASS" 등을 "A Class", "AB Class" 형태로 변환 + const formatValue = (value: string): string => { + // 공백 제거 및 대소문자 정규화 + const cleaned = value.trim().toLowerCase() + + // "class"가 포함되어 있으면 제거 + const withoutClass = cleaned.replace(/\s*class\s*/g, '') + + // 알파벳과 숫자만 추출 + const letters = withoutClass.replace(/[^a-z0-9]/g, '') + + if (letters.length === 0) { + return value.trim() // 변환할 수 없으면 원본 반환 + } + + // 첫 글자를 대문자로 변환하고 "Class" 추가 + return letters.charAt(0).toUpperCase() + letters.slice(1) + " Class" + } + + const formattedValue = formatValue(input.value) + + const [updatedDocumentClass] = await db + .update(documentClasses) + .set({ + value: formattedValue, + description: input.description || "", + updatedAt: new Date(), + }) + .where(eq(documentClasses.id, input.id)) + .returning({ id: documentClasses.id }) + + revalidatePath("/evcp/docu-list-rule/document-class") + + return { + success: true, + data: updatedDocumentClass, + message: "Document Class updated successfully" + } + } catch (error) { + console.error("Error updating document class:", error) + return { + success: false, + error: "Failed to update document class" + } + } +} + +// Document Class 삭제 +export async function deleteDocumentClassCodeGroup(id: number) { + try { + // 삭제할 Document Class의 codeGroupId 확인 + const documentClassToDelete = await db + .select({ codeGroupId: documentClasses.codeGroupId }) + .from(documentClasses) + .where(eq(documentClasses.id, id)) + .limit(1) + + const [deletedDocumentClass] = await db + .delete(documentClasses) + .where(eq(documentClasses.id, id)) + .returning({ id: documentClasses.id }) + + // 같은 codeGroupId를 가진 다른 Document Class가 있는지 확인 + if (documentClassToDelete.length > 0 && documentClassToDelete[0].codeGroupId) { + const remainingClasses = await db + .select({ id: documentClasses.id }) + .from(documentClasses) + .where(eq(documentClasses.codeGroupId, documentClassToDelete[0].codeGroupId)) + .limit(1) + + // 더 이상 Document Class가 없으면 Code Group도 삭제 + if (remainingClasses.length === 0) { + await db + .delete(codeGroups) + .where(eq(codeGroups.id, documentClassToDelete[0].codeGroupId)) + } + } + + revalidatePath("/evcp/docu-list-rule/document-class") + revalidatePath("/evcp/docu-list-rule/code-groups") + + return { + success: true, + data: deletedDocumentClass, + message: "Document Class deleted successfully" + } + } catch (error) { + console.error("Error deleting document class:", error) + return { + success: false, + error: "Failed to delete document class" + } + } +} + +// Document Class 옵션 목록 조회 +export async function getDocumentClassSubOptions(documentClassId: number) { + try { + const data = await db + .select({ + id: documentClassOptions.id, + documentClassId: documentClassOptions.documentClassId, + optionValue: documentClassOptions.optionValue, + optionCode: documentClassOptions.optionCode, + sortOrder: documentClassOptions.sortOrder, + isActive: documentClassOptions.isActive, + createdAt: documentClassOptions.createdAt, + updatedAt: documentClassOptions.updatedAt, + }) + .from(documentClassOptions) + .where(eq(documentClassOptions.documentClassId, documentClassId)) + .orderBy(asc(documentClassOptions.sortOrder), asc(documentClassOptions.optionValue)) + + return { + success: true, + data, + } + } catch (error) { + console.error("Error fetching document class options:", error) + return { + success: false, + error: "Failed to fetch document class options", + data: [], + } + } +} + +// Document Class 옵션 생성 +export async function createDocumentClassOptionItem(input: { + documentClassId: number + optionValue: string +}) { + try { + // Document Class 정보 조회하여 Value 가져오기 + const documentClass = await db + .select({ value: documentClasses.value }) + .from(documentClasses) + .where(eq(documentClasses.id, input.documentClassId)) + .limit(1) + + if (!documentClass.length) { + return { + success: false, + error: "Document Class not found" + } + } + + // Value에서 클래스명 추출 (예: "A Class" → "A") + const classValue = documentClass[0].value + const className = classValue.split(' ')[0] // "A Class"에서 "A" 추출 + + // 자동으로 optionCode 생성 (예: "A_OP_01", "A_OP_02" 등) + const existingOptions = await db + .select({ optionCode: documentClassOptions.optionCode }) + .from(documentClassOptions) + .where(eq(documentClassOptions.documentClassId, input.documentClassId)) + .orderBy(desc(documentClassOptions.optionCode)) + + let newOptionCode = `${className}_OP_01` + if (existingOptions.length > 0) { + const lastOption = existingOptions[0] + if (lastOption.optionCode) { + // "A_OP_01" 형태에서 숫자 추출 + const match = lastOption.optionCode.match(/_OP_(\d+)$/) + if (match) { + const lastNumber = parseInt(match[1]) || 0 + newOptionCode = `${className}_OP_${String(lastNumber + 1).padStart(2, '0')}` + } else { + // 기존 형식이 다른 경우 01부터 시작 + newOptionCode = `${className}_OP_01` + } + } + } + + const [newOption] = await db + .insert(documentClassOptions) + .values({ + documentClassId: input.documentClassId, + optionValue: input.optionValue, + optionCode: newOptionCode, + sortOrder: 0, + isActive: true, + }) + .returning({ id: documentClassOptions.id }) + + revalidatePath("/evcp/docu-list-rule/document-class") + + return { + success: true, + data: newOption, + message: "Document Class option created successfully" + } + } catch (error) { + console.error("Error creating document class option:", error) + return { + success: false, + error: "Failed to create document class option" + } + } +} + +// Document Class 옵션 수정 +export async function updateDocumentClassOption(input: { + id: number + optionValue: string +}) { + try { + const [updatedOption] = await db + .update(documentClassOptions) + .set({ + optionValue: input.optionValue, + updatedAt: new Date(), + }) + .where(eq(documentClassOptions.id, input.id)) + .returning({ id: documentClassOptions.id }) + + revalidatePath("/evcp/docu-list-rule/document-class") + + return { + success: true, + data: updatedOption, + message: "Document Class option updated successfully" + } + } catch (error) { + console.error("Error updating document class option:", error) + return { + success: false, + error: "Failed to update document class option" + } + } +} + +// Document Class 옵션 삭제 +export async function deleteDocumentClassOption(id: number) { + try { + const [deletedOption] = await db + .delete(documentClassOptions) + .where(eq(documentClassOptions.id, id)) + .returning({ id: documentClassOptions.id }) + + revalidatePath("/evcp/docu-list-rule/document-class") + + return { + success: true, + data: deletedOption, + message: "Document Class option deleted successfully" + } + } catch (error) { + console.error("Error deleting document class option:", error) + return { + success: false, + error: "Failed to delete document class option" + } + } +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx b/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx new file mode 100644 index 00000000..677fe8ef --- /dev/null +++ b/lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx @@ -0,0 +1,154 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deleteDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service" +import { documentClasses } from "@/db/schema/documentClasses" + +interface DeleteDocumentClassDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + documentClasses: Row<typeof documentClasses.$inferSelect>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteDocumentClassDialog({ + documentClasses, + showTrigger = true, + onSuccess, + ...props +}: DeleteDocumentClassDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + // 각 Document Class를 순차적으로 삭제 + for (const documentClass of documentClasses) { + const result = await deleteDocumentClassCodeGroup(documentClass.id) + if (!result.success) { + toast.error(`Document Class ${documentClass.code} 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("Document Class가 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("Document Class 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({documentClasses.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{documentClasses.length}</span> + 개의 Document Class를 서버에서 영구적으로 삭제합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({documentClasses.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{documentClasses.length}</span> + 개의 Document Class를 서버에서 영구적으로 삭제합니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx b/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx new file mode 100644 index 00000000..f0fcbc34 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deleteDocumentClassOption } from "@/lib/docu-list-rule/document-class/service" +import { documentClassOptions } from "@/db/schema/documentClasses" + +interface DeleteDocumentClassOptionDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + options: typeof documentClassOptions.$inferSelect[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteDocumentClassOptionDialog({ + options, + showTrigger = true, + onSuccess, + ...props +}: DeleteDocumentClassOptionDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + for (const option of options) { + const result = await deleteDocumentClassOption(option.id) + if (!result.success) { + toast.error(`Document Class 옵션 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("Document Class 옵션이 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("Document Class 옵션 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({options.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{options.length}</span> + 개의 Document Class 옵션을 서버에서 영구적으로 삭제합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({options.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{options.length}</span> + 개의 Document Class 옵션을 서버에서 영구적으로 삭제합니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx b/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx new file mode 100644 index 00000000..ef9c50a8 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx @@ -0,0 +1,145 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" +import { Plus } from "lucide-react" + +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 { createDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service" + +const createDocumentClassSchema = z.object({ + value: z.string().min(1, "Value는 필수입니다."), + description: z.string().optional(), +}) + +type CreateDocumentClassSchema = z.infer<typeof createDocumentClassSchema> + +interface DocumentClassAddDialogProps { + onSuccess?: () => void +} + +export function DocumentClassAddDialog({ + onSuccess, +}: DocumentClassAddDialogProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + + const form = useForm<CreateDocumentClassSchema>({ + resolver: zodResolver(createDocumentClassSchema), + defaultValues: { + value: "", + description: "", + }, + mode: "onChange" + }) + + async function onSubmit(input: CreateDocumentClassSchema) { + startTransition(async () => { + try { + const result = await createDocumentClassCodeGroup({ + value: input.value, + description: input.description, + }) + + if (result.success) { + toast.success("Document Class가 생성되었습니다.") + form.reset() + setOpen(false) + onSuccess?.() + } else { + toast.error(result.error || "생성에 실패했습니다.") + } + } catch (error) { + console.error("Create error:", error) + toast.error("Document Class 생성 중 오류가 발생했습니다.") + } + }) + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + Add + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>Document Class 추가</DialogTitle> + <DialogDescription> + 새로운 Document Class를 추가합니다. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + </DialogDescription> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="value" + render={({ field }) => ( + <FormItem> + <FormLabel>Value *</FormLabel> + <FormControl> + <Input {...field} placeholder="예: A Class" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input {...field} placeholder="예: A Class Description (선택사항)" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter> + <Button type="button" variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button type="submit" disabled={isPending || !form.formState.isValid}> + {isPending ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx b/lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx new file mode 100644 index 00000000..97729caa --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" +import { Loader } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" + +import { updateDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service" +import { documentClasses } from "@/db/schema/documentClasses" + +const updateDocumentClassSchema = z.object({ + value: z.string().min(1, "Value는 필수입니다."), + description: z.string().optional(), +}) + +type UpdateDocumentClassSchema = z.infer<typeof updateDocumentClassSchema> + +interface DocumentClassEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + data: typeof documentClasses.$inferSelect | null + onSuccess?: () => void +} + +export function DocumentClassEditSheet({ + open, + onOpenChange, + data, + onSuccess, +}: DocumentClassEditSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateDocumentClassSchema>({ + resolver: zodResolver(updateDocumentClassSchema), + defaultValues: { + value: data?.value || "", + description: data?.description || "", + }, + mode: "onChange" + }) + + React.useEffect(() => { + if (data) { + form.reset({ + value: data.value || "", + description: data.description || "", + }) + } + }, [data, form]) + + async function onSubmit(input: UpdateDocumentClassSchema) { + if (!data) return + + startUpdateTransition(async () => { + try { + const result = await updateDocumentClassCodeGroup({ + id: data.id, + value: input.value, + description: input.description, + }) + + if (result.success) { + toast.success("Document Class가 성공적으로 수정되었습니다.") + onSuccess?.() + onOpenChange(false) + } else { + toast.error(result.error || "수정에 실패했습니다.") + } + } catch (error) { + console.error("Update error:", error) + toast.error("Document Class 수정 중 오류가 발생했습니다.") + } + }) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Document Class 수정</SheetTitle> + <SheetDescription> + Document Class 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="value" + render={({ field }) => ( + <FormItem> + <FormLabel>Value</FormLabel> + <FormControl> + <Input placeholder="예: A Class" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input placeholder="예: Document Class_1 (선택사항)" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isUpdatePending || !form.formState.isValid} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx new file mode 100644 index 00000000..5bfcbd33 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx @@ -0,0 +1,137 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" +import { Plus } from "lucide-react" + +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 { createDocumentClassOptionItem } from "@/lib/docu-list-rule/document-class/service" +import { documentClasses } from "@/db/schema/documentClasses" + +const createDocumentClassOptionSchema = z.object({ + optionValue: z.string().min(1, "옵션 값은 필수입니다."), +}) + +type CreateDocumentClassOptionSchema = z.infer<typeof createDocumentClassOptionSchema> + +interface DocumentClassOptionAddDialogProps { + selectedDocumentClass: typeof documentClasses.$inferSelect | null + onSuccess?: () => void +} + +export function DocumentClassOptionAddDialog({ + selectedDocumentClass, + onSuccess, +}: DocumentClassOptionAddDialogProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + + const form = useForm<CreateDocumentClassOptionSchema>({ + resolver: zodResolver(createDocumentClassOptionSchema), + defaultValues: { + optionValue: "", + }, + mode: "onChange" + }) + + async function onSubmit(input: CreateDocumentClassOptionSchema) { + if (!selectedDocumentClass) { + toast.error("Document Class가 선택되지 않았습니다.") + return + } + + startTransition(async () => { + try { + const result = await createDocumentClassOptionItem({ + documentClassId: selectedDocumentClass.id, + optionValue: input.optionValue, + }) + + if (result.success) { + toast.success("Document Class 옵션이 생성되었습니다.") + form.reset() + setOpen(false) + onSuccess?.() + } else { + toast.error(result.error || "생성에 실패했습니다.") + } + } catch (error) { + console.error("Create error:", error) + toast.error("Document Class 옵션 생성 중 오류가 발생했습니다.") + } + }) + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + return ( + <Dialog open={open && !!selectedDocumentClass} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" disabled={!selectedDocumentClass}> + <Plus className="mr-2 h-4 w-4" /> + 옵션 추가 + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>Document Class 옵션 추가</DialogTitle> + <DialogDescription> + {selectedDocumentClass?.description || "Document Class"}에 새로운 옵션을 추가합니다. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + </DialogDescription> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="optionValue" + render={({ field }) => ( + <FormItem> + <FormLabel>옵션 값 *</FormLabel> + <FormControl> + <Input {...field} placeholder="옵션 값을 입력하세요" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button type="button" variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button type="submit" disabled={isPending || !form.formState.isValid}> + {isPending ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx b/lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx new file mode 100644 index 00000000..6f6e7a87 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" +import { Loader } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { updateDocumentClassOption } from "@/lib/docu-list-rule/document-class/service" +import { documentClassOptions } from "@/db/schema/documentClasses" + +const updateDocumentClassOptionSchema = z.object({ + optionValue: z.string().min(1, "옵션 값은 필수입니다."), +}) + +type UpdateDocumentClassOptionSchema = z.infer<typeof updateDocumentClassOptionSchema> + +interface DocumentClassOptionEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + data: typeof documentClassOptions.$inferSelect | null + onSuccess?: () => void +} + +export function DocumentClassOptionEditSheet({ + open, + onOpenChange, + data, + onSuccess, +}: DocumentClassOptionEditSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateDocumentClassOptionSchema>({ + resolver: zodResolver(updateDocumentClassOptionSchema), + defaultValues: { + optionValue: data?.optionValue || "", + }, + mode: "onChange" + }) + + React.useEffect(() => { + if (data) { + form.reset({ + optionValue: data.optionValue || "", + }) + } + }, [data, form]) + + async function onSubmit(input: UpdateDocumentClassOptionSchema) { + if (!data) return + + startUpdateTransition(async () => { + try { + const result = await updateDocumentClassOption({ + id: data.id, + optionValue: input.optionValue, + }) + + if (result.success) { + toast.success("Document Class 옵션이 성공적으로 수정되었습니다.") + onSuccess?.() + onOpenChange(false) + } else { + toast.error(result.error || "수정에 실패했습니다.") + } + } catch (error) { + console.error("Update error:", error) + toast.error("Document Class 옵션 수정 중 오류가 발생했습니다.") + } + }) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Document Class 옵션 수정</SheetTitle> + <SheetDescription> + Document Class 옵션 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="optionValue" + render={({ field }) => ( + <FormItem> + <FormLabel>옵션 값</FormLabel> + <FormControl> + <Input placeholder="옵션 값을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isUpdatePending || !form.formState.isValid} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx b/lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx new file mode 100644 index 00000000..c04a7b37 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx @@ -0,0 +1,156 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" + +import { formatDateTime } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { documentClassOptions } from "@/db/schema/documentClasses" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentClassOptions.$inferSelect> | null>> +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof documentClassOptions.$inferSelect>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<typeof documentClassOptions.$inferSelect> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<typeof documentClassOptions.$inferSelect> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + maxSize: 30, + } + + // ---------------------------------------------------------------- + // 3) 데이터 컬럼들 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef<typeof documentClassOptions.$inferSelect>[] = [ + { + accessorKey: "optionCode", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="코드" /> + ), + meta: { + excelHeader: "코드", + type: "text", + }, + cell: ({ row }) => row.getValue("optionCode") ?? "", + minSize: 80 + }, + { + accessorKey: "optionValue", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="옵션 값" /> + ), + meta: { + excelHeader: "옵션 값", + type: "text", + }, + cell: ({ row }) => row.getValue("optionValue") ?? "", + minSize: 80 + }, + + { + accessorKey: "createdAt", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + meta: { + excelHeader: "생성일", + type: "date", + }, + cell: ({ row }) => { + const dateVal = row.getValue("createdAt") as Date + return formatDateTime(dateVal, "KR") + }, + minSize: 80 + } + ] + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, dataColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...dataColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx b/lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx new file mode 100644 index 00000000..5044d90d --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx @@ -0,0 +1,43 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" + +import { DocumentClassOptionAddDialog } from "./document-class-option-add-dialog" +import { DeleteDocumentClassOptionDialog } from "./delete-document-class-option-dialog" +import { documentClasses, documentClassOptions } from "@/db/schema/documentClasses" + +interface DocumentClassOptionsTableToolbarActionsProps<TData> { + table: Table<TData> + selectedDocumentClass: typeof documentClasses.$inferSelect | null + onSuccess?: () => void +} + +export function DocumentClassOptionsTableToolbarActions<TData>({ + table, + selectedDocumentClass, + onSuccess, +}: DocumentClassOptionsTableToolbarActionsProps<TData>) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedOptions = selectedRows.map((row) => row.original as typeof documentClassOptions.$inferSelect) + + return ( + <div className="flex items-center gap-2"> + {/** 선택된 로우가 있으면 삭제 다이얼로그 */} + {selectedOptions.length > 0 ? ( + <DeleteDocumentClassOptionDialog + options={selectedOptions} + onSuccess={() => { + table.toggleAllRowsSelected(false) + onSuccess?.() + }} + /> + ) : null} + + <DocumentClassOptionAddDialog + selectedDocumentClass={selectedDocumentClass} + onSuccess={onSuccess} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-options-table.tsx b/lib/docu-list-rule/document-class/table/document-class-options-table.tsx new file mode 100644 index 00000000..644e3599 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-options-table.tsx @@ -0,0 +1,176 @@ +"use client" + +import * as React from "react" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table" + +import { getDocumentClassSubOptions } from "@/lib/docu-list-rule/document-class/service" +import { getColumns } from "./document-class-options-table-columns" +import { DocumentClassOptionEditSheet } from "./document-class-option-edit-sheet" +import { DeleteDocumentClassOptionDialog } from "./delete-document-class-option-dialog" +import { DocumentClassOptionsTableToolbarActions } from "./document-class-options-table-toolbar" +import { documentClasses, documentClassOptions } from "@/db/schema/docu-list-rule" + +type DocumentClass = typeof documentClasses.$inferSelect + +interface DocumentClassOptionsTableProps { + selectedDocumentClass: DocumentClass | null + documentClasses: DocumentClass[] + onSelectDocumentClass: (documentClass: DocumentClass) => void +} + +export function DocumentClassOptionsTable({ + selectedDocumentClass, + documentClasses, + onSelectDocumentClass +}: DocumentClassOptionsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentClassOptions.$inferSelect> | null>(null) + + // 선택된 Document Class의 옵션 데이터 로드 + const [options, setOptions] = React.useState<typeof documentClassOptions.$inferSelect[]>([]) + + // DB 등록 순서대로 정렬된 Document Classes + const sortedDocumentClasses = React.useMemo(() => { + return [...documentClasses].sort((a, b) => a.id - b.id) + }, [documentClasses]) + + const handleSuccess = React.useCallback(async () => { + // 옵션 테이블 새로고침 + if (selectedDocumentClass) { + try { + const result = await getDocumentClassSubOptions(selectedDocumentClass.id) + if (result.success && result.data) { + setOptions(result.data) + } + } catch (error) { + console.error("Error refreshing options:", error) + } + } + }, [selectedDocumentClass]) + + const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction]) + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<typeof documentClassOptions.$inferSelect>[] = [ + { id: "optionCode", label: "코드", type: "text" }, + { id: "optionValue", label: "옵션 값", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + ] + + const { table } = useDataTable({ + data: options, + columns, + pageCount: 1, + enablePinning: true, + enableAdvancedFilter: true, + manualSorting: false, + initialState: { + sorting: [{ id: "id", desc: false }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + React.useEffect(() => { + const loadOptions = async () => { + if (!selectedDocumentClass) { + setOptions([]) + return + } + + try { + const result = await getDocumentClassSubOptions(selectedDocumentClass.id) + if (result.success && result.data) { + setOptions(result.data) + } + } catch (error) { + console.error("Error loading options:", error) + setOptions([]) + } + } + + loadOptions() + }, [selectedDocumentClass]) + + if (!selectedDocumentClass) { + return ( + <div className="space-y-4"> + <div className="flex gap-2"> + {sortedDocumentClasses.map((documentClass) => ( + <button + key={documentClass.id} + onClick={() => onSelectDocumentClass(documentClass)} + className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${ + selectedDocumentClass?.id === documentClass.id + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground hover:bg-muted/80" + }`} + > + {documentClass.value} + </button> + ))} + </div> + <div className="text-center text-muted-foreground py-4"> + Document Class를 선택하면 옵션을 관리할 수 있습니다. + </div> + </div> + ) + } + + return ( + <> + <div className="space-y-2"> + <div className="flex gap-2"> + {sortedDocumentClasses.map((documentClass) => ( + <button + key={documentClass.id} + onClick={() => onSelectDocumentClass(documentClass)} + className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${ + selectedDocumentClass?.id === documentClass.id + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground hover:bg-muted/80" + }`} + > + {documentClass.value} + </button> + ))} + </div> + + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <DocumentClassOptionsTableToolbarActions + table={table} + selectedDocumentClass={selectedDocumentClass} + onSuccess={handleSuccess} + /> + </DataTableAdvancedToolbar> + </DataTable> + </div> + + <DeleteDocumentClassOptionDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + options={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + handleSuccess() + }} + /> + + <DocumentClassOptionEditSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + data={rowAction?.row.original ?? null} + onSuccess={handleSuccess} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx b/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx new file mode 100644 index 00000000..6684d13a --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx @@ -0,0 +1,169 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" + +import { formatDateTime } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { documentClasses } from "@/db/schema/docu-list-rule" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentClasses.$inferSelect> | null>> +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof documentClasses.$inferSelect>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<typeof documentClasses.$inferSelect> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<typeof documentClasses.$inferSelect> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + maxSize: 30, + } + + // ---------------------------------------------------------------- + // 3) 데이터 컬럼들 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef<typeof documentClasses.$inferSelect>[] = [ + { + accessorKey: "code", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="코드" /> + ), + meta: { + excelHeader: "코드", + type: "text", + }, + cell: ({ row }) => row.getValue("code") ?? "", + minSize: 80 + }, + { + accessorKey: "value", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="값" /> + ), + meta: { + excelHeader: "값", + type: "text", + }, + cell: ({ row }) => row.getValue("value") ?? "", + minSize: 80 + }, + { + accessorKey: "description", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설명" /> + ), + meta: { + excelHeader: "설명", + type: "text", + }, + cell: ({ row }) => row.getValue("description") ?? "", + minSize: 80 + }, + + { + accessorKey: "createdAt", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + meta: { + excelHeader: "생성일", + type: "date", + }, + cell: ({ row }) => { + const dateVal = row.getValue("createdAt") as Date + return formatDateTime(dateVal, "KR") + }, + minSize: 80 + } + ] + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, dataColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...dataColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx b/lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx new file mode 100644 index 00000000..7bc28a06 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx @@ -0,0 +1,34 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" + +import { DeleteDocumentClassDialog } from "./delete-document-class-dialog" +import { DocumentClassAddDialog } from "./document-class-add-dialog" +import { documentClasses } from "@/db/schema/documentClasses" + +interface DocumentClassTableToolbarActionsProps { + table: Table<typeof documentClasses.$inferSelect> + onSuccess?: () => void +} + +export function DocumentClassTableToolbarActions({ table, onSuccess }: DocumentClassTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteDocumentClassDialog + documentClasses={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => { + table.toggleAllRowsSelected(false) + onSuccess?.() + }} + /> + ) : null} + + <DocumentClassAddDialog onSuccess={onSuccess} /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-table.tsx b/lib/docu-list-rule/document-class/table/document-class-table.tsx new file mode 100644 index 00000000..bbe79800 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-table.tsx @@ -0,0 +1,107 @@ +"use client" + +import * as React from "react" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table" +import { getDocumentClassCodeGroups } from "@/lib/docu-list-rule/document-class/service" +import { getColumns } from "./document-class-table-columns" +import { DocumentClassEditSheet } from "./document-class-edit-sheet" +import { DocumentClassOptionsTable } from "./document-class-options-table" +import { DocumentClassTableToolbarActions } from "./document-class-table-toolbar" +import { DeleteDocumentClassDialog } from "./delete-document-class-dialog" +import { documentClasses } from "@/db/schema/docu-list-rule" + +interface DocumentClassTableProps { + promises?: Promise<[{ data: typeof documentClasses.$inferSelect[]; pageCount: number }]> +} + +export function DocumentClassTable({ promises }: DocumentClassTableProps) { + const rawData = React.use(promises!) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentClasses.$inferSelect> | null>(null) + const [selectedDocumentClass, setSelectedDocumentClass] = React.useState<typeof documentClasses.$inferSelect | null>(null) + + const refreshData = React.useCallback(() => { + window.location.reload() + }, []) + + const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction]) + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<typeof documentClasses.$inferSelect>[] = [ + { id: "code", label: "코드", type: "text" }, + { id: "value", label: "값", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + ] + + const { table } = useDataTable({ + data: rawData[0].data as any, + columns, + pageCount: rawData[0].pageCount, + enablePinning: true, + enableAdvancedFilter: true, + manualSorting: false, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <DocumentClassTableToolbarActions table={table} onSuccess={refreshData} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 구분선 */} + <div className="border-t border-border my-6"></div> + + {/* Document Class 옵션 관리 제목 */} + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight">Document Class 옵션 관리</h2> + </div> + <p className="text-muted-foreground"> + Document Class 옵션들을 관리합니다. + </p> + </div> + </div> + + {/* Document Class 옵션 테이블 */} + <DocumentClassOptionsTable + selectedDocumentClass={selectedDocumentClass} + documentClasses={rawData[0].data || []} + onSelectDocumentClass={setSelectedDocumentClass} + /> + + <DeleteDocumentClassDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + documentClasses={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + refreshData() + }} + /> + + <DocumentClassEditSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + data={rowAction?.row.original ?? null} + onSuccess={refreshData} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/validation.ts b/lib/docu-list-rule/document-class/validation.ts new file mode 100644 index 00000000..0600e8fb --- /dev/null +++ b/lib/docu-list-rule/document-class/validation.ts @@ -0,0 +1,12 @@ +import { createSearchParamsCache } from "nuqs/server"; +import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server"; +import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers"; + +export const searchParamsDocumentClassCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<any>(), + filters: getFiltersStateParser(), + search: parseAsString.withDefault(""), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), +});
\ No newline at end of file |
