summaryrefslogtreecommitdiff
path: root/lib/docu-list-rule/combo-box-settings
diff options
context:
space:
mode:
Diffstat (limited to 'lib/docu-list-rule/combo-box-settings')
-rw-r--r--lib/docu-list-rule/combo-box-settings/service.ts368
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx142
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx185
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx234
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx147
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx263
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx162
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx53
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx180
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx33
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx105
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx162
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx85
-rw-r--r--lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx436
-rw-r--r--lib/docu-list-rule/combo-box-settings/validation.ts12
15 files changed, 2567 insertions, 0 deletions
diff --git a/lib/docu-list-rule/combo-box-settings/service.ts b/lib/docu-list-rule/combo-box-settings/service.ts
new file mode 100644
index 00000000..b603ee71
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/service.ts
@@ -0,0 +1,368 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import db from "@/db/db"
+import { codeGroups, comboBoxSettings } from "@/db/schema/docu-list-rule"
+import { eq, sql, count } from "drizzle-orm"
+import { unstable_noStore } from "next/cache"
+
+// Control Type이 combobox인 Code Groups 목록 조회
+export async function getComboBoxCodeGroups(input: {
+ page: number
+ perPage: number
+ search?: string
+ sort?: Array<{ id: string; desc: boolean }>
+ filters?: Array<{ id: string; value: string }>
+ joinOperator?: "and" | "or"
+ flags?: string[]
+ groupId?: string
+ description?: string
+ isActive?: string
+}) {
+ unstable_noStore()
+
+ try {
+ const { page, perPage, sort, search } = input
+ const offset = (page - 1) * perPage
+
+ // Control Type이 combobox인 조건
+ let whereConditions = sql`${codeGroups.controlType} = 'combobox'`
+
+ // 검색 조건
+ if (search) {
+ const searchTerm = `%${search}%`
+ whereConditions = sql`${whereConditions} AND (
+ ${codeGroups.groupId} ILIKE ${searchTerm} OR
+ ${codeGroups.description} ILIKE ${searchTerm} OR
+ ${codeGroups.codeFormat} ILIKE ${searchTerm}
+ )`
+ }
+
+ // 정렬
+ let orderBy = sql`${codeGroups.createdAt} DESC`
+ if (sort && sort.length > 0) {
+ const sortField = sort[0]
+ const direction = sortField.desc ? sql`DESC` : sql`ASC`
+
+ switch (sortField.id) {
+ case "groupId":
+ orderBy = sql`${codeGroups.groupId} ${direction}`
+ break
+ case "description":
+ orderBy = sql`${codeGroups.description} ${direction}`
+ break
+ case "codeFormat":
+ orderBy = sql`${codeGroups.codeFormat} ${direction}`
+ break
+ case "controlType":
+ orderBy = sql`${codeGroups.controlType} ${direction}`
+ break
+ case "isActive":
+ orderBy = sql`${codeGroups.isActive} ${direction}`
+ break
+ case "createdAt":
+ orderBy = sql`${codeGroups.createdAt} ${direction}`
+ break
+ default:
+ orderBy = sql`${codeGroups.createdAt} DESC`
+ }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: codeGroups.id,
+ groupId: codeGroups.groupId,
+ description: codeGroups.description,
+ codeFormat: codeGroups.codeFormat,
+ expressions: codeGroups.expressions,
+ controlType: codeGroups.controlType,
+ isActive: codeGroups.isActive,
+ createdAt: codeGroups.createdAt,
+ updatedAt: codeGroups.updatedAt,
+ })
+ .from(codeGroups)
+ .where(whereConditions)
+ .orderBy(orderBy)
+ .limit(perPage)
+ .offset(offset)
+
+ // 총 개수 조회
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(codeGroups)
+ .where(whereConditions)
+
+ const pageCount = Math.ceil(total / perPage)
+
+ return {
+ success: true,
+ data,
+ pageCount,
+ }
+ } catch (error) {
+ console.error("Error fetching combo box code groups:", error)
+ return {
+ success: false,
+ error: "Failed to fetch combo box code groups",
+ data: [],
+ pageCount: 0,
+ }
+ }
+}
+
+// 특정 Code Group의 Combo Box 옵션 조회
+export async function getComboBoxOptions(codeGroupId: number, input?: {
+ page?: number
+ perPage?: number
+ search?: string
+ sort?: Array<{ id: string; desc: boolean }>
+ filters?: Array<{ id: string; value: string }>
+ joinOperator?: "and" | "or"
+}) {
+ try {
+ const { page = 1, perPage = 10, sort, search } = input || {}
+ const offset = (page - 1) * perPage
+
+ // 기본 조건: codeGroupId
+ let whereConditions = eq(comboBoxSettings.codeGroupId, codeGroupId)
+
+ // 검색 조건
+ if (search) {
+ const searchTerm = `%${search}%`
+ whereConditions = sql`${whereConditions} AND (
+ ${comboBoxSettings.code} ILIKE ${searchTerm} OR
+ ${comboBoxSettings.description} ILIKE ${searchTerm} OR
+ ${comboBoxSettings.remark} ILIKE ${searchTerm}
+ )`
+ }
+
+ // 정렬
+ let orderBy = sql`${comboBoxSettings.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`${comboBoxSettings.code} ${direction}`
+ break
+ case "description":
+ orderBy = sql`${comboBoxSettings.description} ${direction}`
+ break
+ case "remark":
+ orderBy = sql`${comboBoxSettings.remark} ${direction}`
+ break
+ case "createdAt":
+ orderBy = sql`${comboBoxSettings.createdAt} ${direction}`
+ break
+ case "updatedAt":
+ orderBy = sql`${comboBoxSettings.updatedAt} ${direction}`
+ break
+ default:
+ orderBy = sql`${comboBoxSettings.createdAt} DESC`
+ }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: comboBoxSettings.id,
+ codeGroupId: comboBoxSettings.codeGroupId,
+ code: comboBoxSettings.code,
+ description: comboBoxSettings.description,
+ remark: comboBoxSettings.remark,
+ createdAt: comboBoxSettings.createdAt,
+ updatedAt: comboBoxSettings.updatedAt,
+ })
+ .from(comboBoxSettings)
+ .where(whereConditions)
+ .orderBy(orderBy)
+ .limit(perPage)
+ .offset(offset)
+
+ // 총 개수 조회
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(comboBoxSettings)
+ .where(whereConditions)
+
+ const pageCount = Math.ceil(total / perPage)
+
+ return {
+ success: true,
+ data,
+ pageCount,
+ }
+ } catch (error) {
+ console.error("Error fetching combo box options:", error)
+ return {
+ success: false,
+ error: "Failed to fetch combo box options",
+ data: [],
+ pageCount: 0,
+ }
+ }
+}
+
+// Combo Box 옵션 생성
+export async function createComboBoxOption(input: {
+ codeGroupId: number
+ code: string
+ description: string
+ remark?: string
+}) {
+ try {
+ // 해당 Code Group의 정보 가져오기
+ const codeGroup = await db
+ .select({ description: codeGroups.description })
+ .from(codeGroups)
+ .where(eq(codeGroups.id, input.codeGroupId))
+ .limit(1)
+
+ if (codeGroup.length === 0) {
+ return {
+ success: false,
+ error: "Code Group not found"
+ }
+ }
+
+ const codeGroupDescription = codeGroup[0].description
+
+ // 해당 Code Group의 마지막 옵션 번호 찾기
+ const lastOption = await db
+ .select({ code: comboBoxSettings.code })
+ .from(comboBoxSettings)
+ .where(eq(comboBoxSettings.codeGroupId, input.codeGroupId))
+ .orderBy(sql`CAST(SUBSTRING(${comboBoxSettings.code} FROM ${codeGroupDescription.length + 2}) AS INTEGER) DESC`)
+ .limit(1)
+
+ let nextNumber = 1
+ if (lastOption.length > 0 && lastOption[0].code) {
+ const prefix = `${codeGroupDescription}_`
+ if (lastOption[0].code.startsWith(prefix)) {
+ const lastNumber = parseInt(lastOption[0].code.replace(prefix, ''))
+ if (!isNaN(lastNumber)) {
+ nextNumber = lastNumber + 1
+ }
+ }
+ }
+
+ const newCode = `${codeGroupDescription}_${nextNumber}`
+
+ const [newOption] = await db
+ .insert(comboBoxSettings)
+ .values({
+ codeGroupId: input.codeGroupId,
+ code: newCode,
+ description: input.description,
+ remark: input.remark,
+ })
+ .returning({ id: comboBoxSettings.id })
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ data: newOption,
+ message: "Combo Box option created successfully"
+ }
+ } catch (error) {
+ console.error("Error creating combo box option:", error)
+ return {
+ success: false,
+ error: "Failed to create combo box option"
+ }
+ }
+}
+
+// Combo Box 옵션 수정
+export async function updateComboBoxOption(input: {
+ id: number
+ code: string
+ description: string
+ remark?: string
+}) {
+ try {
+ const [updatedOption] = await db
+ .update(comboBoxSettings)
+ .set({
+ code: input.code,
+ description: input.description,
+ remark: input.remark,
+ updatedAt: new Date(),
+ })
+ .where(eq(comboBoxSettings.id, input.id))
+ .returning({ id: comboBoxSettings.id })
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ data: updatedOption,
+ message: "Combo Box option updated successfully"
+ }
+ } catch (error) {
+ console.error("Error updating combo box option:", error)
+ return {
+ success: false,
+ error: "Failed to update combo box option"
+ }
+ }
+}
+
+// Combo Box 옵션 삭제
+export async function deleteComboBoxOption(id: number) {
+ try {
+ const [deletedOption] = await db
+ .delete(comboBoxSettings)
+ .where(eq(comboBoxSettings.id, id))
+ .returning({ id: comboBoxSettings.id })
+
+ if (!deletedOption) {
+ return {
+ success: false,
+ error: "Option not found"
+ }
+ }
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ message: "Combo Box option deleted successfully"
+ }
+ } catch (error) {
+ console.error("Error deleting combo box option:", error)
+ return {
+ success: false,
+ error: "Failed to delete combo box option"
+ }
+ }
+}
+
+// Code Group의 모든 Combo Box 옵션 삭제
+export async function clearComboBoxOptions(codeGroupId: number) {
+ try {
+ const deletedOptions = await db
+ .delete(comboBoxSettings)
+ .where(eq(comboBoxSettings.codeGroupId, codeGroupId))
+ .returning({ id: comboBoxSettings.id })
+
+ revalidatePath("/evcp/docu-list-rule/combo-box-settings")
+
+ return {
+ success: true,
+ data: deletedOptions,
+ message: `Cleared ${deletedOptions.length} Combo Box options successfully`
+ }
+ } catch (error) {
+ console.error("Error clearing combo box options:", error)
+ return {
+ success: false,
+ error: "Failed to clear combo box options"
+ }
+ }
+}
+
+ \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx
new file mode 100644
index 00000000..1fb8950c
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx
@@ -0,0 +1,142 @@
+"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 { createComboBoxOption } from "../service"
+
+const createOptionSchema = z.object({
+ description: z.string().min(1, "값은 필수입니다."),
+ remark: z.string().optional(),
+})
+
+type CreateOptionSchema = z.infer<typeof createOptionSchema>
+
+interface ComboBoxOptionsAddDialogProps {
+ codeGroupId: number
+ onSuccess?: () => void
+}
+
+export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOptionsAddDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<CreateOptionSchema>({
+ resolver: zodResolver(createOptionSchema),
+ defaultValues: {
+ description: "",
+ remark: "",
+ },
+ })
+
+ const handleSubmit = (data: CreateOptionSchema) => {
+ startTransition(async () => {
+ try {
+ const result = await createComboBoxOption({
+ codeGroupId,
+ code: "", // 서비스에서 자동 생성
+ description: data.description,
+ remark: data.remark,
+ })
+
+ if (result.success) {
+ toast.success("옵션이 성공적으로 추가되었습니다.")
+ setOpen(false)
+ form.reset()
+ onSuccess?.()
+ } else {
+ toast.error(`옵션 추가 실패: ${result.error}`)
+ }
+ } catch (error) {
+ console.error("Create error:", error)
+ toast.error("옵션 추가 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const handleCancel = () => {
+ setOpen(false)
+ form.reset()
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 옵션 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>옵션 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 Combo Box 옵션을 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>값</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="옵션 값" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="remark"
+ 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}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx
new file mode 100644
index 00000000..1c145c55
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-detail-sheet.tsx
@@ -0,0 +1,185 @@
+"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 {
+ Sheet,
+ SheetContent,
+} from "@/components/ui/sheet"
+
+import { getComboBoxOptions } from "../service"
+import { getColumns } from "./combo-box-options-table-columns"
+import { ComboBoxOptionsEditSheet } from "./combo-box-options-edit-sheet"
+import { DeleteComboBoxOptionsDialog } from "./delete-combo-box-options-dialog"
+import { ComboBoxOptionsTableToolbarActions } from "./combo-box-options-table-toolbar"
+import { codeGroups } from "@/db/schema/codeGroups"
+
+type ComboBoxOption = {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface ComboBoxOptionsDetailSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ codeGroup: typeof codeGroups.$inferSelect | null
+ onSuccess?: () => void
+ promises?: Promise<[{ data: ComboBoxOption[]; pageCount: number }]>
+}
+
+export function ComboBoxOptionsDetailSheet({
+ open,
+ onOpenChange,
+ codeGroup,
+ onSuccess,
+ promises,
+}: ComboBoxOptionsDetailSheetProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<any> | null>(null)
+ const [rawData, setRawData] = React.useState<{ data: ComboBoxOption[]; pageCount: number }>({ data: [], pageCount: 0 })
+
+ React.useEffect(() => {
+ if (promises) {
+ promises.then(([result]) => {
+ setRawData(result)
+ })
+ } else if (open && codeGroup) {
+ // fallback: 클라이언트에서 직접 fetch (CSR)
+ (async () => {
+ try {
+ const result = await getComboBoxOptions(codeGroup.id, {
+ page: 1,
+ perPage: 10,
+ search: "",
+ sort: [{ id: "createdAt", desc: true }],
+ filters: [],
+ joinOperator: "and",
+ })
+ if (result.success && result.data) {
+ // isActive 필드가 없는 경우 기본값 true로 설정
+ const optionsWithIsActive = result.data.map(option => ({
+ ...option,
+ isActive: (option as any).isActive ?? true
+ }))
+ setRawData({
+ data: optionsWithIsActive,
+ pageCount: result.pageCount || 1
+ })
+ }
+ } catch (error) {
+ console.error("Error refreshing data:", error)
+ }
+ })()
+ }
+ }, [promises, open, codeGroup])
+
+ const refreshData = React.useCallback(async () => {
+ if (!codeGroup) return
+
+ try {
+ const result = await getComboBoxOptions(codeGroup.id, {
+ page: 1,
+ perPage: 10,
+ search: "",
+ sort: [{ id: "createdAt", desc: true }],
+ filters: [],
+ joinOperator: "and",
+ })
+ if (result.success && result.data) {
+ // isActive 필드가 없는 경우 기본값 true로 설정
+ const optionsWithIsActive = result.data.map(option => ({
+ ...option,
+ isActive: (option as any).isActive ?? true
+ }))
+ setRawData({
+ data: optionsWithIsActive,
+ pageCount: result.pageCount || 1
+ })
+ }
+ } catch (error) {
+ console.error("Error refreshing data:", error)
+ }
+ }, [codeGroup])
+
+ const columns = React.useMemo(() => getColumns({ setRowAction: setRowAction as any }), [setRowAction])
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
+ { id: "code", label: "코드", type: "text" },
+ { id: "description", label: "값", type: "text" },
+ { id: "remark", label: "비고", type: "text" },
+ ]
+
+ const { table } = useDataTable({
+ data: rawData.data as any,
+ columns: columns as any,
+ pageCount: rawData.pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ manualSorting: true,
+ manualFiltering: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String((originalRow as any).id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ if (!codeGroup) return null
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-4xl">
+ <div className="flex items-center justify-between">
+ <div>
+ <h3 className="text-lg font-medium">{codeGroup.description} 옵션 관리</h3>
+ <p className="text-sm text-muted-foreground">
+ {codeGroup.groupId}의 Combo Box 옵션들을 관리합니다.
+ </p>
+ </div>
+ </div>
+
+ <ComboBoxOptionsTableToolbarActions
+ table={table as any}
+ codeGroupId={codeGroup.id}
+ onSuccess={refreshData}
+ />
+
+ <DataTable table={table as any}>
+ <DataTableAdvancedToolbar
+ table={table as any}
+ filterFields={advancedFilterFields}
+ />
+ </DataTable>
+
+ <DeleteComboBoxOptionsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ options={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ refreshData()
+ }}
+ />
+
+ <ComboBoxOptionsEditSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ data={rowAction?.row.original ?? null}
+ onSuccess={refreshData}
+ />
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx
new file mode 100644
index 00000000..6459ae14
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx
@@ -0,0 +1,234 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Plus, Trash2 } from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+import { codeGroups } from "@/db/schema/codeGroups"
+import { createComboBoxOption } from "../service"
+
+interface ComboBoxOptionsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ codeGroup: typeof codeGroups.$inferSelect | null
+ onSuccess: () => void
+}
+
+interface OptionRow {
+ id: string
+ description: string
+ remark: string
+}
+
+export function ComboBoxOptionsDialog({
+ open,
+ onOpenChange,
+ codeGroup,
+ onSuccess
+}: ComboBoxOptionsDialogProps) {
+ const [optionRows, setOptionRows] = useState<OptionRow[]>([])
+ const [loading, setLoading] = useState(false)
+
+ // 다이얼로그가 열릴 때 초기 행 생성
+ React.useEffect(() => {
+ if (open && optionRows.length === 0) {
+ addRow()
+ }
+ }, [open])
+
+ // 새 행 추가
+ const addRow = () => {
+ const newRow: OptionRow = {
+ id: `row-${Date.now()}-${Math.random()}`,
+ description: "",
+ remark: "",
+ }
+ setOptionRows(prev => [...prev, newRow])
+ }
+
+ // 행 삭제
+ const removeRow = (id: string) => {
+ setOptionRows(prev => prev.filter(row => row.id !== id))
+ }
+
+ // 행 업데이트
+ const updateRow = (id: string, field: keyof OptionRow, value: string) => {
+ setOptionRows(prev =>
+ prev.map(row =>
+ row.id === id ? { ...row, [field]: value } : row
+ )
+ )
+ }
+
+ // 일괄 저장
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!codeGroup) return
+
+ // 유효한 행들만 필터링 (Description이 있는 것만)
+ const validRows = optionRows.filter(row => row.description.trim())
+
+ if (validRows.length === 0) {
+ toast.error("최소 하나의 Description을 입력해주세요.")
+ return
+ }
+
+ setLoading(true)
+ try {
+ let successCount = 0
+ let errorCount = 0
+
+ // 각 행을 순차적으로 저장
+ for (const row of validRows) {
+ try {
+ const result = await createComboBoxOption({
+ codeGroupId: codeGroup.id,
+ code: "", // 서비스에서 자동 생성
+ description: row.description.trim(),
+ remark: row.remark.trim() || undefined,
+ })
+
+ if (result.success) {
+ successCount++
+ } else {
+ errorCount++
+ }
+ } catch (error) {
+ console.error("옵션 추가 실패:", error)
+ errorCount++
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개의 옵션이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ''}`)
+ // 폼 초기화
+ setOptionRows([])
+ onSuccess()
+ } else {
+ toast.error("모든 옵션 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 추가 실패:", error)
+ toast.error("옵션 추가에 실패했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleCancel = () => {
+ // 폼 초기화
+ setOptionRows([])
+ onOpenChange(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>Combo Box 옵션 추가</DialogTitle>
+ <DialogDescription>
+ {codeGroup?.description}에 새로운 옵션들을 추가합니다. Code는 자동으로 생성됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 테이블 헤더와 추가 버튼 */}
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium">옵션 목록</h4>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addRow}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 행 추가
+ </Button>
+ </div>
+
+ {/* 옵션 테이블 - 항상 표시 */}
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[50%]">Description *</TableHead>
+ <TableHead className="w-[40%]">Remark</TableHead>
+ <TableHead className="w-[10%]"></TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {optionRows.map((row) => (
+ <TableRow key={row.id} className="hover:bg-muted/30">
+ <TableCell>
+ <Input
+ value={row.description}
+ onChange={(e) => updateRow(row.id, "description", e.target.value)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ value={row.remark}
+ onChange={(e) => updateRow(row.id, "remark", e.target.value)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeRow(row.id)}
+ className="h-6 w-6 p-0"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={handleSubmit}
+ disabled={loading}
+ >
+ {loading ? "저장 중..." : "저장"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx
new file mode 100644
index 00000000..5732674e
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet.tsx
@@ -0,0 +1,147 @@
+"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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { updateComboBoxOption } from "../service"
+
+const updateOptionSchema = z.object({
+ value: z.string().min(1, "값은 필수입니다."),
+})
+
+type UpdateOptionSchema = z.infer<typeof updateOptionSchema>
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface ComboBoxOptionsEditSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: ComboBoxOption | null
+ onSuccess?: () => void
+}
+
+export function ComboBoxOptionsEditSheet({
+ open,
+ onOpenChange,
+ data,
+ onSuccess,
+}: ComboBoxOptionsEditSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<UpdateOptionSchema>({
+ resolver: zodResolver(updateOptionSchema),
+ defaultValues: {
+ value: "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (data) {
+ form.reset({
+ value: data.description,
+ })
+ }
+ }, [data, form])
+
+ const handleSubmit = (formData: UpdateOptionSchema) => {
+ if (!data) return
+
+ startTransition(async () => {
+ try {
+ const result = await updateComboBoxOption({
+ id: data.id,
+ value: formData.value,
+ })
+
+ if (result.success) {
+ toast.success("옵션이 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(`옵션 수정 실패: ${result.error}`)
+ }
+ } catch (error) {
+ console.error("Update error:", error)
+ toast.error("옵션 수정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const handleCancel = () => {
+ onOpenChange(false)
+ form.reset()
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>옵션 수정</SheetTitle>
+ <SheetDescription>
+ ComboBox 옵션을 수정합니다.
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="value"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>값</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="옵션 값" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <SheetFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || !form.formState.isValid}>
+ {isPending && (
+ <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/combo-box-settings/table/combo-box-options-expandable-row.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx
new file mode 100644
index 00000000..07b63de5
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx
@@ -0,0 +1,263 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { MoreHorizontal, Settings } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+import { codeGroups } from "@/db/schema/codeGroups"
+import { getComboBoxOptions, updateComboBoxOption, deleteComboBoxOption } from "../service"
+import { DocumentClassOptionsSheet } from "./document-class-options-sheet"
+
+
+interface ComboBoxOptionsExpandableRowProps {
+ codeGroup: typeof codeGroups.$inferSelect
+}
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+export function ComboBoxOptionsExpandableRow({ codeGroup }: ComboBoxOptionsExpandableRowProps) {
+ const [options, setOptions] = useState<ComboBoxOption[]>([])
+ const [loading, setLoading] = useState(true)
+ const [editingOption, setEditingOption] = useState<ComboBoxOption | null>(null)
+ const [selectedOptionForSubOptions, setSelectedOptionForSubOptions] = useState<ComboBoxOption | null>(null)
+
+ // 옵션 목록 로드
+ const loadOptions = async () => {
+ try {
+ setLoading(true)
+ const result = await getComboBoxOptions(codeGroup.id)
+ if (result.success && result.data) {
+ setOptions(result.data as ComboBoxOption[])
+ } else {
+ toast.error("옵션 목록을 불러오는데 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 로드 실패:", error)
+ toast.error("옵션 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadOptions()
+ }, [codeGroup.id])
+
+ // 기존 옵션 수정
+ const handleUpdateOption = async (option: ComboBoxOption) => {
+ if (!option.code.trim() || !option.description.trim()) {
+ toast.error("Code와 Description은 필수 입력 항목입니다.")
+ return
+ }
+
+ try {
+ const result = await updateComboBoxOption({
+ id: option.id,
+ code: option.code.trim(),
+ description: option.description.trim(),
+ remark: option.remark || undefined,
+ })
+
+ if (result.success) {
+ await loadOptions() // 목록 새로고침
+ setEditingOption(null) // 편집 모드 종료
+ toast.success("옵션이 수정되었습니다.")
+ } else {
+ toast.error("옵션 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 수정 실패:", error)
+ toast.error("옵션 수정에 실패했습니다.")
+ }
+ }
+
+ // 기존 옵션 삭제
+ const handleDeleteOption = async (optionId: number) => {
+ if (!confirm("정말로 이 옵션을 삭제하시겠습니까?")) {
+ return
+ }
+
+ try {
+ const result = await deleteComboBoxOption(optionId)
+ if (result.success) {
+ await loadOptions() // 목록 새로고침
+ toast.success("옵션이 삭제되었습니다.")
+ } else {
+ toast.error("옵션 삭제에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("옵션 삭제 실패:", error)
+ toast.error("옵션 삭제에 실패했습니다.")
+ }
+ }
+
+ // Document Class인지 확인 (Description이 "Document Class"인 경우)
+ const isDocumentClass = codeGroup.description === "Document Class"
+
+ return (
+ <div className="bg-muted/20 border-t">
+ <div className="space-y-0 ml-[60px]">
+ {/* 커스텀 테이블 */}
+ <div className="border overflow-hidden bg-white">
+ <Table className="w-full table-fixed">
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[20%] font-medium text-muted-foreground">Code</TableHead>
+ <TableHead className="w-[30%] font-medium text-muted-foreground">Description</TableHead>
+ <TableHead className="w-[25%] font-medium text-muted-foreground">Remark</TableHead>
+ {isDocumentClass && (
+ <TableHead className="w-[15%] font-medium text-muted-foreground">하위 옵션</TableHead>
+ )}
+ <TableHead className="w-[10%] font-medium text-muted-foreground">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {/* 기존 옵션들 */}
+ {options.map((option) => (
+ <TableRow key={option.id} className="hover:bg-muted/30 transition-colors">
+ <TableCell className="font-medium text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.code}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, code: e.target.value } : null)}
+ placeholder="Code (*)"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.code
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.description}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, description: e.target.value } : null)}
+ placeholder="Description (*)"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.description
+ )}
+ </TableCell>
+ <TableCell className="text-sm text-muted-foreground">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.remark || ""}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, remark: e.target.value } : null)}
+ placeholder="Remark"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.remark || "-"
+ )}
+ </TableCell>
+ {isDocumentClass && (
+ <TableCell className="text-sm">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setSelectedOptionForSubOptions(option)}
+ className="h-6 px-2 text-xs"
+ >
+ <Settings className="h-3 w-3 mr-1" />
+ 관리
+ </Button>
+ </TableCell>
+ )}
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <div className="flex gap-1">
+ <Button
+ onClick={() => handleUpdateOption(editingOption)}
+ size="sm"
+ variant="outline"
+ className="h-6 px-2 text-xs"
+ >
+ 저장
+ </Button>
+ <Button
+ onClick={() => setEditingOption(null)}
+ size="sm"
+ variant="ghost"
+ className="h-6 px-2 text-xs"
+ >
+ 취소
+ </Button>
+ </div>
+ ) : (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="h-6 w-6 p-0"
+ >
+ <MoreHorizontal className="h-3 w-3" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setEditingOption(option)}>
+ 수정
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => handleDeleteOption(option.id)}>
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+
+ {options.length === 0 && (
+ <TableRow>
+ <TableCell colSpan={isDocumentClass ? 5 : 4} className="text-center text-muted-foreground py-8">
+ 등록된 옵션이 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+
+ {/* Document Class 하위 옵션 관리 시트 */}
+ {selectedOptionForSubOptions && (
+ <DocumentClassOptionsSheet
+ open={!!selectedOptionForSubOptions}
+ onOpenChange={(open) => !open && setSelectedOptionForSubOptions(null)}
+ comboBoxOption={selectedOptionForSubOptions}
+ onSuccess={() => {
+ setSelectedOptionForSubOptions(null)
+ // 필요시 하위 옵션 목록 새로고침
+ }}
+ />
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx
new file mode 100644
index 00000000..e5780e9e
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns.tsx
@@ -0,0 +1,162 @@
+"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 { 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"
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive?: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ComboBoxOption> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ComboBoxOption>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<ComboBoxOption> = {
+ 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<ComboBoxOption> = {
+ 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<ComboBoxOption>[] = [
+ {
+ accessorKey: "code",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="코드" />
+ ),
+ meta: {
+ excelHeader: "코드",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("code") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="값" />
+ ),
+ meta: {
+ excelHeader: "값",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("description") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "remark",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ meta: {
+ excelHeader: "비고",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("remark") ?? "",
+ minSize: 80
+ }
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, dataColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...dataColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx
new file mode 100644
index 00000000..7318efb8
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { ComboBoxOptionsAddDialog } from "./combo-box-options-add-dialog"
+import { DeleteComboBoxOptionsDialog } from "./delete-combo-box-options-dialog"
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface ComboBoxOptionsTableToolbarActionsProps {
+ table: Table<ComboBoxOption>
+ codeGroupId: number
+ onSuccess?: () => void
+}
+
+export function ComboBoxOptionsTableToolbarActions({
+ table,
+ codeGroupId,
+ onSuccess,
+}: ComboBoxOptionsTableToolbarActionsProps) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedOptions = selectedRows.map((row) => row.original)
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {selectedOptions.length > 0 ? (
+ <DeleteComboBoxOptionsDialog
+ options={selectedOptions}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+ <ComboBoxOptionsAddDialog
+ codeGroupId={codeGroupId}
+ onSuccess={onSuccess}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx
new file mode 100644
index 00000000..efce54b4
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-columns.tsx
@@ -0,0 +1,180 @@
+"use client"
+
+import * as React from "react"
+
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { codeGroups } from "@/db/schema/docu-list-rule"
+
+interface GetColumnsProps {
+ onDetail?: (codeGroup: typeof codeGroups.$inferSelect) => void
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({ onDetail }: GetColumnsProps): ColumnDef<typeof codeGroups.$inferSelect>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<typeof codeGroups.$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 codeGroups.$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={() => onDetail?.(row.original)}
+ >
+ Detail
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<typeof codeGroups.$inferSelect>[] = [
+ {
+ accessorKey: "groupId",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Group ID" />
+ ),
+ meta: {
+ excelHeader: "Group ID",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("groupId") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "description",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ meta: {
+ excelHeader: "Description",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("description") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "codeFormat",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Code Format" />
+ ),
+ meta: {
+ excelHeader: "Code Format",
+ type: "text",
+ },
+ cell: ({ row }) => row.getValue("codeFormat") ?? "",
+ minSize: 80
+ },
+ {
+ accessorKey: "controlType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Control Type" />
+ ),
+ meta: {
+ excelHeader: "Control Type",
+ type: "text",
+ },
+ cell: ({ row }) => {
+ const controlType = row.getValue("controlType") as string
+ return (
+ <Badge variant="outline">
+ {controlType}
+ </Badge>
+ )
+ },
+ minSize: 80
+ },
+
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ meta: {
+ excelHeader: "Created At",
+ 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/combo-box-settings/table/combo-box-settings-table-toolbar.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx
new file mode 100644
index 00000000..77cbea01
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { DeleteComboBoxSettingsDialog } from "./delete-combo-box-settings-dialog"
+import { codeGroups } from "@/db/schema/codeGroups"
+
+interface ComboBoxSettingsTableToolbarActionsProps {
+ table: Table<typeof codeGroups.$inferSelect>
+ onSuccess?: () => void
+}
+
+export function ComboBoxSettingsTableToolbarActions({ table, onSuccess }: ComboBoxSettingsTableToolbarActionsProps) {
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteComboBoxSettingsDialog
+ codeGroups={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ onSuccess?.()
+ }}
+ />
+ ) : null}
+
+
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx
new file mode 100644
index 00000000..356b2706
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table.tsx
@@ -0,0 +1,105 @@
+"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 } from "@/types/table"
+import { getComboBoxCodeGroups } from "../service"
+import { getColumns } from "./combo-box-settings-table-columns"
+import { ComboBoxOptionsDetailSheet } from "./combo-box-options-detail-sheet"
+import { ComboBoxSettingsTableToolbarActions } from "./combo-box-settings-table-toolbar"
+import { codeGroups } from "@/db/schema/docu-list-rule"
+
+interface ComboBoxSettingsTableProps {
+ promises?: Promise<[{ data: typeof codeGroups.$inferSelect[]; pageCount: number }]>
+}
+
+export function ComboBoxSettingsTable({ promises }: ComboBoxSettingsTableProps) {
+ const rawData = React.use(promises!)
+ const [isDetailSheetOpen, setIsDetailSheetOpen] = React.useState(false)
+ const [selectedCodeGroup, setSelectedCodeGroup] = React.useState<typeof codeGroups.$inferSelect | null>(null)
+
+ const refreshData = React.useCallback(() => {
+ window.location.reload()
+ }, [])
+
+ // Detail 버튼 클릭 핸들러
+ const handleDetail = (codeGroup: typeof codeGroups.$inferSelect) => {
+ setSelectedCodeGroup(codeGroup)
+ setIsDetailSheetOpen(true)
+ }
+
+ const columns = React.useMemo(() => getColumns({ onDetail: handleDetail }), [handleDetail])
+
+ // 고급 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<typeof codeGroups.$inferSelect>[] = [
+ { id: "groupId", label: "Group ID", type: "text" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "codeFormat", label: "Code Format", type: "text" },
+ {
+ id: "controlType", label: "Control Type", type: "select", options: [
+ { label: "Textbox", value: "textbox" },
+ { label: "Combobox", value: "combobox" },
+ { label: "Date", value: "date" },
+ { label: "Number", value: "number" },
+ ]
+ },
+ {
+ id: "isActive", label: "Status", type: "select", options: [
+ { label: "Active", value: "true" },
+ { label: "Inactive", value: "false" },
+ ]
+ },
+ { id: "createdAt", label: "Created At", 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"] },
+ columnFilters: [
+ {
+ id: "controlType",
+ value: "combobox",
+ },
+ ],
+ },
+ getRowId: (originalRow) => String(originalRow.groupId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <ComboBoxSettingsTableToolbarActions table={table} onSuccess={refreshData} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Detail 시트 */}
+ <ComboBoxOptionsDetailSheet
+ open={isDetailSheetOpen}
+ onOpenChange={setIsDetailSheetOpen}
+ codeGroup={selectedCodeGroup}
+ onSuccess={() => {
+ setIsDetailSheetOpen(false)
+ setSelectedCodeGroup(null)
+ }}
+
+ />
+
+
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx
new file mode 100644
index 00000000..e3d8bd23
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog.tsx
@@ -0,0 +1,162 @@
+"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 { deleteComboBoxOption } from "../service"
+
+interface ComboBoxOption {
+ id: number
+ codeGroupId: number
+ code: string
+ description: string
+ remark: string | null
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface DeleteComboBoxOptionsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ options: ComboBoxOption[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteComboBoxOptionsDialog({
+ options,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteComboBoxOptionsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ for (const option of options) {
+ const result = await deleteComboBoxOption(option.id)
+ if (!result.success) {
+ toast.error(`ComboBox 옵션 삭제 실패: ${result.error}`)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("ComboBox 옵션이 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("ComboBox 옵션 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ 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>
+ 개의 ComboBox 옵션을 서버에서 영구적으로 삭제합니다.
+ </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>
+ 개의 ComboBox 옵션을 서버에서 영구적으로 삭제합니다.
+ </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/combo-box-settings/table/delete-combo-box-settings-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx
new file mode 100644
index 00000000..28788bd7
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx
@@ -0,0 +1,85 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { AlertTriangle } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { deleteCodeGroup } from "@/lib/docu-list-rule/code-groups/service"
+import { codeGroups } from "@/db/schema/codeGroups"
+
+interface DeleteComboBoxSettingsDialogProps {
+ codeGroups: typeof codeGroups.$inferSelect[]
+ onSuccess?: () => void
+}
+
+export function DeleteComboBoxSettingsDialog({
+ codeGroups,
+ onSuccess,
+}: DeleteComboBoxSettingsDialogProps) {
+ const router = useRouter()
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ const handleDelete = React.useCallback(async () => {
+ if (codeGroups.length === 0) return
+
+ setIsDeleting(true)
+ try {
+ for (const codeGroup of codeGroups) {
+ await deleteCodeGroup(codeGroup.id)
+ }
+
+ router.refresh()
+ onSuccess?.()
+ } catch (error) {
+ console.error("Error deleting code groups:", error)
+ } finally {
+ setIsDeleting(false)
+ }
+ }, [codeGroups, router, onSuccess])
+
+ if (codeGroups.length === 0) {
+ return null
+ }
+
+ return (
+ <Dialog>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-destructive" />
+ Code Group 삭제
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 Code Group{codeGroups.length > 1 ? "들" : ""}을 삭제하시겠습니까?
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ disabled={isDeleting}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx b/lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx
new file mode 100644
index 00000000..8585d9a3
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx
@@ -0,0 +1,436 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Plus, Trash2, Save, X } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+import {
+ getDocumentClassOptions,
+ createDocumentClassOption,
+ updateDocumentClassOption,
+ deleteDocumentClassOption
+} from "../service"
+
+interface DocumentClassOptionsSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ comboBoxOption: {
+ id: number
+ code: string
+ description: string
+ }
+ onSuccess: () => void
+}
+
+interface DocumentClassOption {
+ id: number
+ comboBoxSettingId: number
+ optionValue: string
+ optionCode: string | null
+ sortOrder: number
+ isActive: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface NewOptionRow {
+ id: string
+ optionValue: string
+ optionCode: string
+ sortOrder: number
+}
+
+export function DocumentClassOptionsSheet({
+ open,
+ onOpenChange,
+ comboBoxOption,
+ onSuccess
+}: DocumentClassOptionsSheetProps) {
+ const [options, setOptions] = useState<DocumentClassOption[]>([])
+ const [loading, setLoading] = useState(true)
+ const [newOptionRows, setNewOptionRows] = useState<NewOptionRow[]>([])
+ const [editingOption, setEditingOption] = useState<DocumentClassOption | null>(null)
+
+ // 하위 옵션 목록 로드
+ const loadOptions = async () => {
+ try {
+ setLoading(true)
+ const result = await getDocumentClassOptions(comboBoxOption.id)
+ if (result.success && result.data) {
+ setOptions(result.data)
+ } else {
+ toast.error("하위 옵션 목록을 불러오는데 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 로드 실패:", error)
+ toast.error("하위 옵션 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ if (open) {
+ loadOptions()
+ }
+ }, [open, comboBoxOption.id])
+
+ // 새 행 추가
+ const addNewRow = () => {
+ const newRow: NewOptionRow = {
+ id: `new-${Date.now()}-${Math.random()}`,
+ optionValue: "",
+ optionCode: "",
+ sortOrder: options.length + newOptionRows.length + 1,
+ }
+ setNewOptionRows(prev => [...prev, newRow])
+ }
+
+ // 새 행 삭제
+ const removeNewRow = (id: string) => {
+ setNewOptionRows(prev => prev.filter(row => row.id !== id))
+ }
+
+ // 새 행 업데이트
+ const updateNewRow = (id: string, field: keyof NewOptionRow, value: string | number) => {
+ setNewOptionRows(prev =>
+ prev.map(row =>
+ row.id === id ? { ...row, [field]: value } : row
+ )
+ )
+ }
+
+ // 새 하위 옵션 저장
+ const handleSaveNewOptions = async () => {
+ const validRows = newOptionRows.filter(row => row.optionValue.trim())
+
+ if (validRows.length === 0) {
+ toast.error("최소 하나의 옵션 값을 입력해주세요.")
+ return
+ }
+
+ try {
+ let successCount = 0
+ let errorCount = 0
+
+ for (const row of validRows) {
+ try {
+ const result = await createDocumentClassOption({
+ comboBoxSettingId: comboBoxOption.id,
+ optionValue: row.optionValue.trim(),
+ optionCode: row.optionCode.trim() || undefined,
+ sortOrder: row.sortOrder,
+ })
+
+ if (result.success) {
+ successCount++
+ } else {
+ errorCount++
+ }
+ } catch (error) {
+ console.error("하위 옵션 추가 실패:", error)
+ errorCount++
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개의 하위 옵션이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ''}`)
+ setNewOptionRows([])
+ await loadOptions()
+ onSuccess()
+ } else {
+ toast.error("모든 하위 옵션 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 추가 실패:", error)
+ toast.error("하위 옵션 추가에 실패했습니다.")
+ }
+ }
+
+ // 기존 하위 옵션 수정
+ const handleUpdateOption = async (option: DocumentClassOption) => {
+ if (!option.optionValue.trim()) {
+ toast.error("옵션 값은 필수 입력 항목입니다.")
+ return
+ }
+
+ try {
+ const result = await updateDocumentClassOption({
+ id: option.id,
+ optionValue: option.optionValue.trim(),
+ optionCode: option.optionCode || undefined,
+ sortOrder: option.sortOrder,
+ isActive: option.isActive,
+ })
+
+ if (result.success) {
+ await loadOptions()
+ setEditingOption(null)
+ toast.success("하위 옵션이 수정되었습니다.")
+ } else {
+ toast.error("하위 옵션 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 수정 실패:", error)
+ toast.error("하위 옵션 수정에 실패했습니다.")
+ }
+ }
+
+ // 하위 옵션 삭제
+ const handleDeleteOption = async (optionId: number) => {
+ if (!confirm("정말로 이 하위 옵션을 삭제하시겠습니까?")) {
+ return
+ }
+
+ try {
+ const result = await deleteDocumentClassOption(optionId)
+ if (result.success) {
+ await loadOptions()
+ toast.success("하위 옵션이 삭제되었습니다.")
+ } else {
+ toast.error("하위 옵션 삭제에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("하위 옵션 삭제 실패:", error)
+ toast.error("하위 옵션 삭제에 실패했습니다.")
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[600px] sm:max-w-[600px] overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle>하위 옵션 관리</SheetTitle>
+ <SheetDescription>
+ {comboBoxOption.description} ({comboBoxOption.code})의 하위 옵션을 관리합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="space-y-4 mt-6">
+ {/* 새 하위 옵션 추가 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium">새 하위 옵션 추가</h4>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addNewRow}
+ className="h-8"
+ >
+ <Plus className="h-4 w-4 mr-1" />
+ 옵션 추가
+ </Button>
+ </div>
+
+ {newOptionRows.length > 0 && (
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[40%]">옵션 값 *</TableHead>
+ <TableHead className="w-[30%]">옵션 코드</TableHead>
+ <TableHead className="w-[20%]">순서</TableHead>
+ <TableHead className="w-[10%]"></TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {newOptionRows.map((row) => (
+ <TableRow key={row.id} className="hover:bg-muted/30">
+ <TableCell>
+ <Input
+ value={row.optionValue}
+ onChange={(e) => updateNewRow(row.id, "optionValue", e.target.value)}
+ placeholder="옵션 값"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ value={row.optionCode}
+ onChange={(e) => updateNewRow(row.id, "optionCode", e.target.value)}
+ placeholder="옵션 코드 (선택)"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ type="number"
+ value={row.sortOrder}
+ onChange={(e) => updateNewRow(row.id, "sortOrder", parseInt(e.target.value) || 0)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Button
+ onClick={() => removeNewRow(row.id)}
+ size="sm"
+ variant="ghost"
+ className="h-6 w-6 p-0"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ <div className="p-3 border-t">
+ <Button
+ onClick={handleSaveNewOptions}
+ size="sm"
+ className="h-8"
+ >
+ <Save className="h-4 w-4 mr-1" />
+ 저장
+ </Button>
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 기존 하위 옵션 목록 */}
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">기존 하위 옵션</h4>
+ <div className="border rounded-lg overflow-hidden">
+ <Table>
+ <TableHeader>
+ <TableRow className="bg-muted/30">
+ <TableHead className="w-[35%]">옵션 값</TableHead>
+ <TableHead className="w-[25%]">옵션 코드</TableHead>
+ <TableHead className="w-[15%]">순서</TableHead>
+ <TableHead className="w-[15%]">상태</TableHead>
+ <TableHead className="w-[10%]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {loading ? (
+ <TableRow>
+ <TableCell colSpan={5} className="text-center py-8">
+ 로딩 중...
+ </TableCell>
+ </TableRow>
+ ) : options.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={5} className="text-center text-muted-foreground py-8">
+ 등록된 하위 옵션이 없습니다.
+ </TableCell>
+ </TableRow>
+ ) : (
+ options.map((option) => (
+ <TableRow key={option.id} className="hover:bg-muted/30">
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.optionValue}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, optionValue: e.target.value } : null)}
+ placeholder="옵션 값"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.optionValue
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ value={editingOption.optionCode || ""}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, optionCode: e.target.value } : null)}
+ placeholder="옵션 코드"
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.optionCode || "-"
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <Input
+ type="number"
+ value={editingOption.sortOrder}
+ onChange={(e) => setEditingOption(prev => prev ? { ...prev, sortOrder: parseInt(e.target.value) || 0 } : null)}
+ className="border-0 focus-visible:ring-1 bg-transparent h-8"
+ />
+ ) : (
+ option.sortOrder
+ )}
+ </TableCell>
+ <TableCell className="text-sm">
+ <span className={`px-2 py-1 rounded text-xs ${
+ option.isActive
+ ? "bg-green-100 text-green-800"
+ : "bg-red-100 text-red-800"
+ }`}>
+ {option.isActive ? "활성" : "비활성"}
+ </span>
+ </TableCell>
+ <TableCell className="text-sm">
+ {editingOption?.id === option.id ? (
+ <div className="flex gap-1">
+ <Button
+ onClick={() => handleUpdateOption(editingOption)}
+ size="sm"
+ variant="outline"
+ className="h-6 px-2 text-xs"
+ >
+ 저장
+ </Button>
+ <Button
+ onClick={() => setEditingOption(null)}
+ size="sm"
+ variant="ghost"
+ className="h-6 px-2 text-xs"
+ >
+ 취소
+ </Button>
+ </div>
+ ) : (
+ <div className="flex gap-1">
+ <Button
+ onClick={() => setEditingOption(option)}
+ size="sm"
+ variant="outline"
+ className="h-6 px-2 text-xs"
+ >
+ 수정
+ </Button>
+ <Button
+ onClick={() => handleDeleteOption(option.id)}
+ size="sm"
+ variant="ghost"
+ className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ </div>
+ )}
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/docu-list-rule/combo-box-settings/validation.ts b/lib/docu-list-rule/combo-box-settings/validation.ts
new file mode 100644
index 00000000..a83651be
--- /dev/null
+++ b/lib/docu-list-rule/combo-box-settings/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 searchParamsComboBoxOptionsCache = 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