summaryrefslogtreecommitdiff
path: root/lib/docu-list-rule/document-class
diff options
context:
space:
mode:
Diffstat (limited to 'lib/docu-list-rule/document-class')
-rw-r--r--lib/docu-list-rule/document-class/service.ts462
-rw-r--r--lib/docu-list-rule/document-class/table/delete-document-class-dialog.tsx154
-rw-r--r--lib/docu-list-rule/document-class/table/delete-document-class-option-dialog.tsx152
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx145
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-edit-sheet.tsx160
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx137
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-option-edit-sheet.tsx143
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx156
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-options-table-toolbar.tsx43
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-options-table.tsx176
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-table-columns.tsx169
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-table-toolbar.tsx34
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-table.tsx107
-rw-r--r--lib/docu-list-rule/document-class/validation.ts12
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