diff options
Diffstat (limited to 'lib/docu-list-rule')
53 files changed, 8649 insertions, 0 deletions
diff --git a/lib/docu-list-rule/code-groups/service.ts b/lib/docu-list-rule/code-groups/service.ts new file mode 100644 index 00000000..34ec5610 --- /dev/null +++ b/lib/docu-list-rule/code-groups/service.ts @@ -0,0 +1,290 @@ +"use server" + +import { revalidatePath } from "next/cache" +import db from "@/db/db" +import { codeGroups, comboBoxSettings, documentClasses } from "@/db/schema/docu-list-rule" +import { eq, sql, count } from "drizzle-orm" +import { unstable_noStore } from "next/cache" + +// Code Groups 목록 조회 +export async function getCodeGroups(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 + controlType?: string + isActive?: string +} | any) { + unstable_noStore() + + try { + const { page, perPage, sort, search } = input + const offset = (page - 1) * perPage + + // 검색 조건 (Document Class 제외) + let whereConditions = sql`${codeGroups.groupId} != 'DOC_CLASS'` + if (search) { + const searchTerm = `%${search}%` + whereConditions = sql`${codeGroups.groupId} != 'DOC_CLASS' AND ( + ${codeGroups.groupId} ILIKE ${searchTerm} OR + ${codeGroups.description} ILIKE ${searchTerm} OR + ${codeGroups.codeFormat} ILIKE ${searchTerm} OR + ${codeGroups.controlType} 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) + + // 총 개수 조회 (Document Class 제외) + const [{ count: total }] = await db + .select({ count: count() }) + .from(codeGroups) + .where(whereConditions) + + const pageCount = Math.ceil(total / perPage) + + return { + data, + pageCount, + total, + } + } catch (error) { + console.error("Error fetching code groups:", error) + return { + data: [], + pageCount: 0, + total: 0, + } + } +} + +// Code Group 생성 +export async function createCodeGroup(input: { + description: string + codeFormat?: string + expressions?: string + controlType: string + isActive?: boolean +}) { + try { + // 마지막 Code Group의 groupId를 찾아서 다음 번호 생성 (DOC_CLASS 제외) + const lastCodeGroup = await db + .select({ groupId: codeGroups.groupId }) + .from(codeGroups) + .where(sql`${codeGroups.groupId} != 'DOC_CLASS'`) + .orderBy(sql`CAST(SUBSTRING(${codeGroups.groupId}, 6) AS INTEGER) DESC`) + .limit(1) + + let nextNumber = 1 + if (lastCodeGroup.length > 0 && lastCodeGroup[0].groupId) { + const lastNumber = parseInt(lastCodeGroup[0].groupId.replace('Code_', '')) + if (!isNaN(lastNumber)) { + nextNumber = lastNumber + 1 + } + } + + const newGroupId = `Code_${nextNumber}` + + // 새 Code Group 생성 + const [newCodeGroup] = await db + .insert(codeGroups) + .values({ + groupId: newGroupId, + description: input.description, + codeFormat: input.codeFormat, + expressions: input.expressions, + controlType: input.controlType, + isActive: input.isActive ?? true, + }) + .returning({ id: codeGroups.id, groupId: codeGroups.groupId }) + + revalidatePath("/evcp/docu-list-rule/code-groups") + + return { + success: true, + data: newCodeGroup, + message: "Code Group created successfully" + } + } catch (error) { + console.error("Error creating code group:", error) + return { + success: false, + error: "Failed to create code group" + } + } +} + +// Code Group 수정 +export async function updateCodeGroup(input: { + id: number + description: string + codeFormat?: string + expressions?: string + controlType: string + isActive?: boolean +}) { + try { + const [updatedCodeGroup] = await db + .update(codeGroups) + .set({ + description: input.description, + codeFormat: input.codeFormat, + expressions: input.expressions, + controlType: input.controlType, + isActive: input.isActive, + updatedAt: new Date(), + }) + .where(eq(codeGroups.id, input.id)) + .returning({ id: codeGroups.id }) + + revalidatePath("/evcp/docu-list-rule/code-groups") + + return { + success: true, + data: updatedCodeGroup, + message: "Code Group updated successfully" + } + } catch (error) { + console.error("Error updating code group:", error) + return { + success: false, + error: "Failed to update code group" + } + } +} + +// Code Group 삭제 +export async function deleteCodeGroup(id: number) { + try { + // Code Group 정보 조회 + const codeGroup = await db + .select({ + id: codeGroups.id, + controlType: codeGroups.controlType, + description: codeGroups.description + }) + .from(codeGroups) + .where(eq(codeGroups.id, id)) + .limit(1) + + if (codeGroup.length === 0) { + return { + success: false, + error: "Code Group not found" + } + } + + // Control Type이 combobox인 경우 관련 Combo Box 옵션들도 삭제 + if (codeGroup[0].controlType === 'combobox') { + // Combo Box 옵션들 삭제 + await db + .delete(comboBoxSettings) + .where(eq(comboBoxSettings.codeGroupId, id)) + } + + // Document Class가 연결된 경우 Document Class도 삭제 + await db + .delete(documentClasses) + .where(eq(documentClasses.codeGroupId, id)) + + // Code Group 삭제 + await db + .delete(codeGroups) + .where(eq(codeGroups.id, id)) + + revalidatePath("/evcp/docu-list-rule/code-groups") + revalidatePath("/evcp/docu-list-rule/combo-box-settings") + revalidatePath("/evcp/docu-list-rule/document-class") + + return { + success: true, + message: codeGroup[0].controlType === 'combobox' + ? `Code Group과 관련 Combo Box 옵션들이 삭제되었습니다.` + : "Code Group이 삭제되었습니다." + } + } catch (error) { + console.error("Error deleting code group:", error) + return { + success: false, + error: "Failed to delete code group" + } + } +} + +// Code Group 단일 조회 +export async function getCodeGroupById(id: number) { + try { + const [codeGroup] = 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(eq(codeGroups.id, id)) + .limit(1) + + return codeGroup || null + } catch (error) { + console.error("Error fetching code group by id:", error) + return null + } +}
\ No newline at end of file diff --git a/lib/docu-list-rule/code-groups/table/code-groups-add-dialog.tsx b/lib/docu-list-rule/code-groups/table/code-groups-add-dialog.tsx new file mode 100644 index 00000000..660adfed --- /dev/null +++ b/lib/docu-list-rule/code-groups/table/code-groups-add-dialog.tsx @@ -0,0 +1,236 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" +import { Plus, Loader2 } 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { createCodeGroup } from "../service" +import { z } from "zod" + +const createCodeGroupSchema = z.object({ + description: z.string().min(1, "Description은 필수입니다."), + codeFormat: z.string().optional().refine((val) => { + if (!val) return true; // 빈 값은 허용 + return /^[AN]*$/.test(val); + }, "Code Format은 A(영어 대문자) 또는 N(숫자)만 입력 가능합니다."), + controlType: z.string().min(1, "Control Type은 필수입니다."), +}) + +type CreateCodeGroupFormValues = z.infer<typeof createCodeGroupSchema> + +interface CodeGroupsAddDialogProps { + onSuccess?: () => void +} + +export function CodeGroupsAddDialog({ onSuccess }: CodeGroupsAddDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm<CreateCodeGroupFormValues>({ + resolver: zodResolver(createCodeGroupSchema), + defaultValues: { + description: "", + codeFormat: "", + controlType: "", + }, + }) + + // Code Format을 기반으로 정규식 자동 생성 함수 + const generateExpression = (codeFormat: string): string => { + if (!codeFormat) return '' + + let expression = '^' + let currentChar = codeFormat[0] + let count = 1 + + for (let i = 1; i < codeFormat.length; i++) { + if (codeFormat[i] === currentChar) { + count++ + } else { + // 이전 문자에 대한 정규식 추가 + if (currentChar === 'A') { + expression += `[A-Z]{${count}}` + } else if (currentChar === 'N') { + expression += `[0-9]{${count}}` + } + currentChar = codeFormat[i] + count = 1 + } + } + + // 마지막 문자 처리 + if (currentChar === 'A') { + expression += `[A-Z]{${count}}` + } else if (currentChar === 'N') { + expression += `[0-9]{${count}}` + } + + expression += '$' + return expression + } + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + if (!newOpen) { + form.reset() + } + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + const onSubmit = async (data: CreateCodeGroupFormValues) => { + setIsLoading(true) + try { + // Expression 자동 생성 + const expressions = generateExpression(data.codeFormat || "") + + const result = await createCodeGroup({ + description: data.description, + codeFormat: data.codeFormat, + expressions: expressions, + controlType: data.controlType, + }) + + if (result.success) { + toast.success("Code Group이 성공적으로 생성되었습니다.") + form.reset() + setOpen(false) + onSuccess?.() + } else { + toast.error(result.error || "생성 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("Code Group 생성 오류:", error) + toast.error("Code Group 생성에 실패했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + Add + </Button> + </DialogTrigger> + <DialogContent className="w-[400px] sm:w-[540px]"> + <DialogHeader> + <DialogTitle>Code Group 생성</DialogTitle> + <DialogDescription> + 새로운 Code Group을 생성합니다. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input placeholder="예: PROJECT NO" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="codeFormat" + render={({ field }) => ( + <FormItem> + <FormLabel>Code Format</FormLabel> + <FormControl> + <Input + placeholder="예: AANNN (A:영어대문자, N:숫자)" + {...field} + onBlur={() => form.trigger('codeFormat')} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="controlType" + render={({ field }) => ( + <FormItem> + <FormLabel>Control Type</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Control Type을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="textbox">Textbox</SelectItem> + <SelectItem value="combobox">Combobox</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + + </form> + </Form> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/code-groups/table/code-groups-edit-sheet.tsx b/lib/docu-list-rule/code-groups/table/code-groups-edit-sheet.tsx new file mode 100644 index 00000000..28aebd54 --- /dev/null +++ b/lib/docu-list-rule/code-groups/table/code-groups-edit-sheet.tsx @@ -0,0 +1,259 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" +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 { Switch } from "@/components/ui/switch" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { updateCodeGroup } from "../service" +import { codeGroups } from "@/db/schema/codeGroups" +import { z } from "zod" + +const updateCodeGroupSchema = z.object({ + description: z.string().min(1, "Description은 필수입니다."), + codeFormat: z.string().optional().refine((val) => { + if (!val) return true; // 빈 값은 허용 + return /^[AN]*$/.test(val); + }, "Code Format은 A(영어 대문자) 또는 N(숫자)만 입력 가능합니다."), + controlType: z.string().min(1, "Control Type은 필수입니다."), + isActive: z.boolean().default(true), +}) + +type UpdateCodeGroupFormValues = z.infer<typeof updateCodeGroupSchema> + +interface CodeGroupsEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + data: typeof codeGroups.$inferSelect | null + onSuccess: () => void +} + +export function CodeGroupsEditSheet({ + open, + onOpenChange, + data, + onSuccess, +}: CodeGroupsEditSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateCodeGroupFormValues>({ + resolver: zodResolver(updateCodeGroupSchema), + defaultValues: { + description: data?.description ?? "", + codeFormat: data?.codeFormat ?? "", + controlType: data?.controlType ?? "", + isActive: data?.isActive ?? true, + }, + mode: "onChange" + }) + + // Code Format을 기반으로 정규식 자동 생성 함수 + const generateExpression = (codeFormat: string): string => { + if (!codeFormat) return '' + + let expression = '^' + let currentChar = codeFormat[0] + let count = 1 + + for (let i = 1; i < codeFormat.length; i++) { + if (codeFormat[i] === currentChar) { + count++ + } else { + // 이전 문자에 대한 정규식 추가 + if (currentChar === 'A') { + expression += `[A-Z]{${count}}` + } else if (currentChar === 'N') { + expression += `[0-9]{${count}}` + } + currentChar = codeFormat[i] + count = 1 + } + } + + // 마지막 문자 처리 + if (currentChar === 'A') { + expression += `[A-Z]{${count}}` + } else if (currentChar === 'N') { + expression += `[0-9]{${count}}` + } + + expression += '$' + return expression + } + + React.useEffect(() => { + if (data) { + form.reset({ + description: data.description, + codeFormat: data.codeFormat || "", + controlType: data.controlType, + isActive: data.isActive ?? true, + }) + } + }, [data, form]) + + async function onSubmit(input: UpdateCodeGroupFormValues) { + if (!data) return + + startUpdateTransition(async () => { + try { + // Code Format이 변경되면 Expression 자동 업데이트 + const expressions = generateExpression(input.codeFormat || "") + + const result = await updateCodeGroup({ + id: data.id, + description: input.description, + codeFormat: input.codeFormat, + expressions: expressions, + controlType: input.controlType, + isActive: input.isActive, + }) + + if (result.success) { + toast.success("Code Group이 성공적으로 수정되었습니다.") + onSuccess() + onOpenChange(false) + } else { + toast.error(result.error || "Code Group 수정에 실패했습니다.") + } + } catch (error) { + console.error("Update error:", error) + toast.error("Code Group 수정 중 오류가 발생했습니다.") + } + }) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Code Group 수정</SheetTitle> + <SheetDescription> + Code Group 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input placeholder="예: PROJECT NO" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="codeFormat" + render={({ field }) => ( + <FormItem> + <FormLabel>Code Format</FormLabel> + <FormControl> + <Input + placeholder="예: AANNN (A:영어대문자, N:숫자)" + {...field} + onBlur={() => form.trigger('codeFormat')} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="controlType" + render={({ field }) => ( + <FormItem> + <FormLabel>Control Type</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Control Type을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="textbox">Textbox</SelectItem> + <SelectItem value="combobox">Combobox</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base">활성 상태</FormLabel> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </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/code-groups/table/code-groups-table-columns.tsx b/lib/docu-list-rule/code-groups/table/code-groups-table-columns.tsx new file mode 100644 index 00000000..cb6cdf8b --- /dev/null +++ b/lib/docu-list-rule/code-groups/table/code-groups-table-columns.tsx @@ -0,0 +1,190 @@ +"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 { Badge } from "@/components/ui/badge" +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 { codeGroups } from "@/db/schema/docu-list-rule" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof codeGroups.$inferSelect> | null>> +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: 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: 20, + 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={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + maxSize: 20, + } + + // ---------------------------------------------------------------- + // 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: 60 + }, + { + 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: 70 + }, + + { + 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: 70 + }, + { + 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: 60 + } + ] + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, dataColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...dataColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/docu-list-rule/code-groups/table/code-groups-table-toolbar.tsx b/lib/docu-list-rule/code-groups/table/code-groups-table-toolbar.tsx new file mode 100644 index 00000000..d2d9efb4 --- /dev/null +++ b/lib/docu-list-rule/code-groups/table/code-groups-table-toolbar.tsx @@ -0,0 +1,38 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" + +import { DeleteCodeGroupsDialog } from "./delete-code-groups-dialog" +import { CodeGroupsAddDialog } from "./code-groups-add-dialog" +import { codeGroups } from "@/db/schema/codeGroups" + +interface CodeGroupsTableToolbarActionsProps<TData> { + table: Table<TData> + onSuccess?: () => void +} + +export function CodeGroupsTableToolbarActions<TData>({ + table, + onSuccess, +}: CodeGroupsTableToolbarActionsProps<TData>) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedCodeGroups = selectedRows.map((row) => row.original as typeof codeGroups.$inferSelect) + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {selectedCodeGroups.length > 0 ? ( + <DeleteCodeGroupsDialog + codeGroups={selectedCodeGroups} + onSuccess={() => { + table.toggleAllRowsSelected(false) + onSuccess?.() + }} + /> + ) : null} + + <CodeGroupsAddDialog onSuccess={onSuccess} /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/code-groups/table/code-groups-table.tsx b/lib/docu-list-rule/code-groups/table/code-groups-table.tsx new file mode 100644 index 00000000..6d8bb907 --- /dev/null +++ b/lib/docu-list-rule/code-groups/table/code-groups-table.tsx @@ -0,0 +1,110 @@ +"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, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { getCodeGroups } from "../service"; +import { getColumns } from "./code-groups-table-columns"; +import { DeleteCodeGroupsDialog } from "./delete-code-groups-dialog"; +import { CodeGroupsEditSheet } from "./code-groups-edit-sheet"; +import { CodeGroupsTableToolbarActions } from "./code-groups-table-toolbar"; +import { codeGroups } from "@/db/schema/docu-list-rule"; + +interface CodeGroupsTableProps { + promises?: Promise<[{ data: typeof codeGroups.$inferSelect[]; pageCount: number }] >; +} + +export function CodeGroupsTable({ promises }: CodeGroupsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof codeGroups.$inferSelect> | null>(null); + + const [{ data, pageCount }] = promises ? React.use(promises) : [{ data: [], pageCount: 0 }]; + + const refreshData = React.useCallback(async () => { + // 페이지 새로고침으로 처리 + window.location.reload(); + }, []); + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 필터 필드 설정 + const filterFields: DataTableFilterField<typeof codeGroups.$inferSelect>[] = []; + + // 고급 필터 필드 설정 + 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, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.groupId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <CodeGroupsTableToolbarActions table={table} onSuccess={refreshData} /> + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteCodeGroupsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + codeGroups={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + refreshData() + }} + /> + + <CodeGroupsEditSheet + 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/code-groups/table/delete-code-groups-dialog.tsx b/lib/docu-list-rule/code-groups/table/delete-code-groups-dialog.tsx new file mode 100644 index 00000000..66a8d7c2 --- /dev/null +++ b/lib/docu-list-rule/code-groups/table/delete-code-groups-dialog.tsx @@ -0,0 +1,203 @@ +"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 { codeGroups } from "@/db/schema/codeGroups" +import { deleteCodeGroup } from "../service" + +interface DeleteCodeGroupsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + codeGroups: Row<typeof codeGroups.$inferSelect>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteCodeGroupsDialog({ + codeGroups, + showTrigger = true, + onSuccess, + ...props +}: DeleteCodeGroupsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + // Combo Box Code Group이 있는지 확인 + const hasComboBoxGroups = codeGroups.some(group => group.controlType === 'combobox') + const comboBoxGroups = codeGroups.filter(group => group.controlType === 'combobox') + + function onDelete() { + startDeleteTransition(async () => { + try { + // 각 Code Group을 순차적으로 삭제 + for (const codeGroup of codeGroups) { + const result = await deleteCodeGroup(codeGroup.id) + if (!result.success) { + toast.error(`Code Group ${codeGroup.description} 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("Code Group이 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("Code Group 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + delete ({codeGroups.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription className="space-y-2"> + <p> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{codeGroups.length}</span> + 개의 Code Group을 서버에서 영구적으로 삭제합니다. + </p> + + {hasComboBoxGroups && ( + <div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-md"> + <p className="text-sm font-medium text-amber-800 mb-1"> + ⚠️ Combo Box 옵션 삭제 경고 + </p> + <p className="text-sm text-amber-700"> + 다음 {comboBoxGroups.length}개의 Combo Box Code Group이 포함되어 있습니다: + </p> + <ul className="text-sm text-amber-700 mt-1 ml-4 list-disc"> + {comboBoxGroups.map((group) => ( + <li key={group.id}> + <strong>{group.description}</strong> ({group.groupId}) + </li> + ))} + </ul> + <p className="text-sm text-amber-700 mt-2"> + 이 Code Group들을 삭제하면 관련된 모든 Combo Box 옵션들도 함께 삭제됩니다. + </p> + </div> + )} + </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" /> + delete ({codeGroups.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription className="space-y-2"> + <p> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{codeGroups.length}</span> + 개의 Code Group을 서버에서 영구적으로 삭제합니다. + </p> + + {hasComboBoxGroups && ( + <div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-md"> + <p className="text-sm font-medium text-amber-800 mb-1"> + ⚠️ Combo Box 옵션 삭제 경고 + </p> + <p className="text-sm text-amber-700"> + 다음 {comboBoxGroups.length}개의 Combo Box Code Group이 포함되어 있습니다: + </p> + <ul className="text-sm text-amber-700 mt-1 ml-4 list-disc"> + {comboBoxGroups.map((group) => ( + <li key={group.id}> + <strong>{group.description}</strong> ({group.groupId}) + </li> + ))} + </ul> + <p className="text-sm text-amber-700 mt-2"> + 이 Code Group들을 삭제하면 관련된 모든 Combo Box 옵션들도 함께 삭제됩니다. + </p> + </div> + )} + </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" /> + )} + delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/code-groups/validation.ts b/lib/docu-list-rule/code-groups/validation.ts new file mode 100644 index 00000000..90e06d0c --- /dev/null +++ b/lib/docu-list-rule/code-groups/validation.ts @@ -0,0 +1,34 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { codeGroups } from "@/db/schema/docu-list-rule"; + +export const searchParamsCodeGroupsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<typeof codeGroups.$inferSelect>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + groupId: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + controlType: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +export type GetCodeGroupsSchema = Awaited<ReturnType<typeof searchParamsCodeGroupsCache.parse>>
\ No newline at end of file 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 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 diff --git a/lib/docu-list-rule/number-type-configs/service.ts b/lib/docu-list-rule/number-type-configs/service.ts new file mode 100644 index 00000000..3e2cfc8e --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/service.ts @@ -0,0 +1,268 @@ +"use server" + +import { revalidatePath } from "next/cache" +import db from "@/db/db" +import { documentNumberTypes, documentNumberTypeConfigs } from "@/db/schema/docu-list-rule" +import { codeGroups, documentClasses } from "@/db/schema/docu-list-rule" +import { eq, asc, sql } from "drizzle-orm" +import { unstable_noStore } from "next/cache" + + + +// 특정 Number Type의 Configs 조회 +export async function getNumberTypeConfigs(numberTypeId: number) { + console.log("=== getNumberTypeConfigs START ===") + console.log("getNumberTypeConfigs called with numberTypeId:", numberTypeId) + + try { + console.log("About to execute database query...") + + const configs = await db + .select({ + id: documentNumberTypeConfigs.id, + documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId, + codeGroupId: documentNumberTypeConfigs.codeGroupId, + documentClassId: documentNumberTypeConfigs.documentClassId, + sdq: documentNumberTypeConfigs.sdq, + description: documentNumberTypeConfigs.description, + remark: documentNumberTypeConfigs.remark, + isActive: documentNumberTypeConfigs.isActive, + createdAt: documentNumberTypeConfigs.createdAt, + updatedAt: documentNumberTypeConfigs.updatedAt, + // Code Group 정보도 함께 가져오기 + codeGroupName: codeGroups.description, + codeGroupControlType: codeGroups.controlType, + // Document Class 정보도 함께 가져오기 + documentClassName: documentClasses.value, + documentClassDescription: documentClasses.description, + }) + .from(documentNumberTypeConfigs) + .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) + .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) + .where(eq(documentNumberTypeConfigs.documentNumberTypeId, numberTypeId)) + .orderBy(asc(documentNumberTypeConfigs.sdq)) + + + + return { + success: true, + data: configs, + } + } catch (error) { + + return { + success: false, + error: "Failed to fetch number type configs", + data: [], + } + } +} + +// Number Type Config 생성 +export async function createNumberTypeConfig(input: { + documentNumberTypeId: number + codeGroupId: number | null + documentClassId: number | null + sdq: number + description?: string + remark?: string +}) { + try { + const [newConfig] = await db + .insert(documentNumberTypeConfigs) + .values({ + documentNumberTypeId: input.documentNumberTypeId, + codeGroupId: input.codeGroupId, + documentClassId: input.documentClassId, + sdq: input.sdq, + description: input.description, + remark: input.remark, + }) + .returning({ id: documentNumberTypeConfigs.id }) + + revalidatePath("/evcp/docu-list-rule/number-type-configs") + + return { + success: true, + data: newConfig, + message: "Number Type Config created successfully" + } + } catch (error) { + console.error("Error creating number type config:", error) + return { + success: false, + error: "Failed to create number type config" + } + } +} + +// Number Type Config 수정 +export async function updateNumberTypeConfig(input: { + id: number + codeGroupId: number | null + documentClassId: number | null + sdq: number + description?: string + remark?: string +}) { + try { + const [updatedConfig] = await db + .update(documentNumberTypeConfigs) + .set({ + codeGroupId: input.codeGroupId, + documentClassId: input.documentClassId, + sdq: input.sdq, + description: input.description, + remark: input.remark, + updatedAt: new Date(), + }) + .where(eq(documentNumberTypeConfigs.id, input.id)) + .returning({ id: documentNumberTypeConfigs.id }) + + revalidatePath("/evcp/docu-list-rule/number-type-configs") + + return { + success: true, + data: updatedConfig, + message: "Number Type Config updated successfully" + } + } catch (error) { + console.error("Error updating number type config:", error) + return { + success: false, + error: "Failed to update number type config" + } + } +} + +// Number Type Config 삭제 +export async function deleteNumberTypeConfig(id: number) { + try { + // 삭제할 항목의 정보를 먼저 가져옴 + const [configToDelete] = await db + .select({ + documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId, + sdq: documentNumberTypeConfigs.sdq, + }) + .from(documentNumberTypeConfigs) + .where(eq(documentNumberTypeConfigs.id, id)) + + if (!configToDelete) { + return { + success: false, + error: "Config not found" + } + } + + // 항목 삭제 + await db + .delete(documentNumberTypeConfigs) + .where(eq(documentNumberTypeConfigs.id, id)) + + // 같은 Number Type의 남은 항목들 중에서 삭제된 항목보다 큰 순서를 가진 항목들의 순서를 1씩 감소 + await db + .update(documentNumberTypeConfigs) + .set({ + sdq: sql`${documentNumberTypeConfigs.sdq} - 1`, + updatedAt: new Date(), + }) + .where( + sql`${documentNumberTypeConfigs.documentNumberTypeId} = ${configToDelete.documentNumberTypeId} AND ${documentNumberTypeConfigs.sdq} > ${configToDelete.sdq}` + ) + + revalidatePath("/evcp/docu-list-rule/number-type-configs") + + return { + success: true, + message: "Number Type Config deleted successfully" + } + } catch (error) { + console.error("Error deleting number type config:", error) + return { + success: false, + error: "Failed to delete number type config" + } + } +} + +// 활성화된 Code Groups 조회 (Config 생성/수정 시 사용) +export async function getActiveCodeGroups() { + try { + console.log("getActiveCodeGroups: 함수 시작") + + const codeGroupsData = await db + .select({ + id: codeGroups.id, + groupId: codeGroups.groupId, + description: codeGroups.description, + controlType: codeGroups.controlType, + isActive: codeGroups.isActive, + }) + .from(codeGroups) + .where(eq(codeGroups.isActive, true)) + .orderBy(asc(codeGroups.description)) + + console.log("getActiveCodeGroups: 쿼리 결과", codeGroupsData) + + return { + success: true, + data: codeGroupsData, + } + } catch (error) { + console.error("Error fetching active code groups:", error) + return { + success: false, + error: "Failed to fetch active code groups", + data: [], + } + } +} + +// Document Class Code Group ID 조회 +export async function getDocumentClassCodeGroupId() { + try { + const [codeGroup] = await db + .select({ id: codeGroups.id }) + .from(codeGroups) + .where(eq(codeGroups.groupId, 'DOC_CLASS')) + .limit(1) + + return codeGroup?.id || null + } catch (error) { + console.error("Error fetching document class code group id:", error) + return null + } +} + +// 활성화된 Document Classes 조회 (Config 생성/수정 시 사용) +export async function getActiveDocumentClasses() { + try { + console.log("getActiveDocumentClasses: 함수 시작") + + const documentClassesData = await db + .select({ + id: documentClasses.id, + code: documentClasses.code, + value: documentClasses.value, + description: documentClasses.description, + isActive: documentClasses.isActive, + }) + .from(documentClasses) + .where(eq(documentClasses.isActive, true)) + .orderBy(asc(documentClasses.value)) + + console.log("getActiveDocumentClasses: 쿼리 결과", documentClassesData) + + return { + success: true, + data: documentClassesData, + } + } catch (error) { + console.error("Error fetching active document classes:", error) + return { + success: false, + error: "Failed to fetch active document classes", + data: [], + } + } +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog.tsx b/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog.tsx new file mode 100644 index 00000000..9ed8ca71 --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog.tsx @@ -0,0 +1,156 @@ +"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 { deleteNumberTypeConfig } from "../service" +import { NumberTypeConfig } from "../../types" + +interface DeleteNumberTypeConfigsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + configs: NumberTypeConfig[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteNumberTypeConfigsDialog({ + configs, + showTrigger = true, + onSuccess, + ...props +}: DeleteNumberTypeConfigsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + // 삭제할 항목들을 sdq 순서대로 정렬 (큰 순서부터 삭제하여 순서 재정렬 문제 방지) + const sortedConfigs = [...configs].sort((a, b) => b.sdq - a.sdq) + + // 각 config를 순차적으로 삭제 (큰 순서부터 삭제) + for (const config of sortedConfigs) { + const result = await deleteNumberTypeConfig(config.id) + if (!result.success) { + toast.error(`Number Type Config 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("Number Type Config가 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("Number Type Config 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({configs.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{configs.length}</span> + 개의 Number Type Config를 서버에서 영구적으로 삭제합니다. + </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" /> + 삭제 ({configs.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{configs.length}</span> + 개의 Number Type Config를 서버에서 영구적으로 삭제합니다. + </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/number-type-configs/table/number-type-configs-edit-dialog.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx new file mode 100644 index 00000000..d3215958 --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx @@ -0,0 +1,222 @@ +"use client" + +import * as React from "react" +import { Loader2 } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" + +import { updateNumberTypeConfig, getActiveCodeGroups } from "../service" +import { NumberTypeConfig } from "../../types" + +interface NumberTypeConfigsEditDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + data: NumberTypeConfig | null + onSuccess?: () => void + existingConfigs?: NumberTypeConfig[] // 기존 configs 목록 추가 +} + +export function NumberTypeConfigsEditDialog({ + open, + onOpenChange, + data, + onSuccess, + existingConfigs = [], // 기본값 추가 +}: NumberTypeConfigsEditDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([]) + const [formData, setFormData] = React.useState({ + codeGroupId: "", + sdq: "", + description: "", + remark: "" + }) + + // 데이터가 변경될 때 폼 초기화 + React.useEffect(() => { + if (data) { + setFormData({ + codeGroupId: data.codeGroupId.toString(), + sdq: data.sdq.toString(), + description: data.description || "", + remark: data.remark || "" + }) + } + }, [data]) + + // Code Groups 로드 + React.useEffect(() => { + (async () => { + try { + const result = await getActiveCodeGroups() + if (result.success && result.data) { + setCodeGroups(result.data) + } + } catch (error) { + console.error("Error loading code groups:", error) + } + })() + }, []) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!data || !formData.codeGroupId || !formData.sdq) { + toast.error("필수 필드를 모두 입력해주세요.") + return + } + + const newSdq = parseInt(formData.sdq) + + // 순서 중복 검증 (현재 수정 중인 항목 제외) + const existingSdq = existingConfigs.find(config => + config.sdq === newSdq && config.id !== data.id + ) + if (existingSdq) { + toast.error(`순서 ${newSdq}번은 이미 사용 중입니다. 다른 순서를 입력해주세요.`) + return + } + + setIsLoading(true) + try { + const result = await updateNumberTypeConfig({ + id: data.id, + codeGroupId: parseInt(formData.codeGroupId), + sdq: newSdq, + description: formData.description || undefined, + remark: formData.remark || undefined, + }) + + if (result.success) { + toast.success("Number Type Config가 성공적으로 수정되었습니다.") + onOpenChange(false) + onSuccess?.() + } else { + toast.error(result.error || "수정에 실패했습니다.") + } + } catch (error) { + console.error("Error updating number type config:", error) + toast.error("수정 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + if (!data) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Number Type Config 수정</DialogTitle> + <DialogDescription> + Number Type Config 정보를 수정합니다. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + </DialogDescription> + </DialogHeader> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="grid gap-4 py-2"> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="codeGroup" className="text-right"> + Code Group <span className="text-red-500">*</span> + </Label> + <div className="col-span-3"> + <Select + value={formData.codeGroupId} + onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))} + > + <SelectTrigger> + <SelectValue placeholder="Code Group 선택" /> + </SelectTrigger> + <SelectContent> + {codeGroups.map((codeGroup) => ( + <SelectItem key={codeGroup.id} value={codeGroup.id.toString()}> + {codeGroup.description} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="sdq" className="text-right"> + 순서 <span className="text-red-500">*</span> + </Label> + <div className="col-span-3"> + <Input + id="sdq" + type="number" + value={formData.sdq} + onChange={(e) => setFormData(prev => ({ ...prev, sdq: e.target.value }))} + min="1" + /> + </div> + </div> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="description" className="text-right"> + Description + </Label> + <div className="col-span-3"> + <Input + id="description" + value={formData.description} + onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="예: PROJECT NO" + /> + </div> + </div> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="remark" className="text-right"> + Remark + </Label> + <div className="col-span-3"> + <Textarea + id="remark" + value={formData.remark} + onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))} + placeholder="비고 사항" + rows={3} + /> + </div> + </div> + </div> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "수정 중..." : "수정"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-table-columns.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table-columns.tsx new file mode 100644 index 00000000..b03000e0 --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table-columns.tsx @@ -0,0 +1,188 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { MoreHorizontal } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Checkbox } from "@/components/ui/checkbox" +import type { DataTableRowAction } from "@/types/table" +import { NumberTypeConfig } from "../../types" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<NumberTypeConfig> | null>> +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<NumberTypeConfig>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모든 행 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + size: 35, + }, + { + accessorKey: "sdq", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="순서" /> + ), + meta: { + excelHeader: "순서", + type: "number", + }, + cell: ({ row }) => row.getValue("sdq") ?? "", + minSize: 50 + }, + { + accessorKey: "codeGroupName", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Code Group" /> + ), + meta: { + excelHeader: "Code Group", + type: "text", + }, + cell: ({ row }) => { + const codeGroupName = row.getValue("codeGroupName") as string | null + const documentClassName = row.original.documentClassName + + if (codeGroupName) { + return codeGroupName + } else if (documentClassName) { + return "Document Class" + } else { + return "-" + } + }, + minSize: 100 + }, + { + accessorKey: "codeGroupControlType", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Control Type" /> + ), + meta: { + excelHeader: "Control Type", + type: "text", + }, + cell: ({ row }) => { + const controlType = row.getValue("codeGroupControlType") as string | null + const documentClassName = row.original.documentClassName + + if (controlType) { + return ( + <Badge variant="outline"> + {controlType} + </Badge> + ) + } else if (documentClassName) { + return ( + <Badge variant="outline"> + ComboBox + </Badge> + ) + } else { + return "-" + } + }, + minSize: 90 + }, + { + accessorKey: "description", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Description" /> + ), + meta: { + excelHeader: "Description", + type: "text", + }, + cell: ({ row }) => { + const description = row.getValue("description") as string | null + const documentClassName = row.original.documentClassName + + if (description) { + return description + } else if (documentClassName) { + return "class 및 하위옵션" + } else { + return "-" + } + }, + minSize: 100 + }, + { + accessorKey: "remark", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Remark" /> + ), + meta: { + excelHeader: "Remark", + type: "text", + }, + cell: ({ row }) => row.getValue("remark") ?? "-", + minSize: 80 + }, + + { + id: "actions", + header: () => <span className="sr-only">작업</span>, + cell: ({ row }) => { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">열기 메뉴</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}> + 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "delete" })}> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + maxSize: 40, + enableSorting: false, + enableHiding: false, + }, + ] +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-table.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table.tsx new file mode 100644 index 00000000..89a92a88 --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-table.tsx @@ -0,0 +1,720 @@ +"use client" + +import * as React from "react" +import { useDataTable } from "@/hooks/use-data-table" +import { flexRender } from "@tanstack/react-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table" + +import { getNumberTypeConfigs, createNumberTypeConfig, getActiveCodeGroups, getActiveDocumentClasses, getDocumentClassCodeGroupId, updateNumberTypeConfig } from "../service" +import { getNumberTypes } from "@/lib/docu-list-rule/number-types/service" +import { getColumns } from "./number-type-configs-table-columns" +import { DeleteNumberTypeConfigsDialog } from "./delete-number-type-configs-dialog" +import { NumberTypeConfigsEditDialog } from "./number-type-configs-edit-dialog" +import { documentNumberTypes } from "@/db/schema/docu-list-rule" +import { Plus, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { toast } from "sonner" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +import { cn } from "@/lib/utils" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { DataTablePagination } from "@/components/data-table/data-table-pagination" +import { NumberTypeConfig } from "../../types" + +interface NumberTypeConfigsTableProps { + promises?: Promise<[{ data: typeof documentNumberTypes.$inferSelect[]; pageCount: number }]> +} + +// 드래그 가능한 행 컴포넌트 +function SortableRow({ + children, + id, + isDragging +}: { + children: React.ReactNode + id: string + isDragging: boolean +}) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( + <TableRow + ref={setNodeRef} + style={style} + className={cn( + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + isDragging && "opacity-50" + )} + > + <TableCell className="w-8 p-1 text-center"> + <div + {...attributes} + {...listeners} + className="cursor-grab active:cursor-grabbing flex items-center justify-center w-5 h-5 text-muted-foreground hover:text-foreground" + > + <span className="text-sm">≡</span> + </div> + </TableCell> + {children} + </TableRow> + ) +} + +// 커스텀 드래그 앤 드롭 테이블 컴포넌트 +function DragDropTable<TData>({ + table, + data, + onDragEnd, + children, + className, + maxHeight = '35rem' +}: { + table: any + data: TData[] + onDragEnd: (event: DragEndEvent) => void + children?: React.ReactNode + className?: string + maxHeight?: string +}) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + return ( + <div className={cn("w-full space-y-2.5 overflow-auto", className)}> + {children} + <div className="max-w-[100vw] overflow-auto" style={{ maxHeight }}> + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + onDragEnd={onDragEnd} + > + <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> + <TableHeader> + <TableRow> + <TableHead className="w-8 p-1 text-center"></TableHead> + {table.getHeaderGroups()[0].headers.map((header: any) => { + if (header.column.getIsGrouped()) { + return null + } + + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + data-column-id={header.column.id} + className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" + style={{ + width: header.getSize(), + }} + > + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + </TableHead> + ) + })} + </TableRow> + </TableHeader> + + <TableBody> + <SortableContext items={data.map((item: any) => item.id.toString())} strategy={verticalListSortingStrategy}> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row: any) => { + if (row.getIsGrouped()) { + return null + } + + return ( + <SortableRow key={row.id} id={row.id} isDragging={false}> + {row.getVisibleCells().map((cell: any) => { + if (cell.column.getIsGrouped()) { + return null + } + + return ( + <TableCell + key={cell.id} + data-column-id={cell.column.id} + className="p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" + style={{ + width: cell.column.getSize(), + }} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </TableCell> + ) + })} + </SortableRow> + ) + }) + ) : ( + <TableRow> + <TableCell + colSpan={table.getAllColumns().length + 1} + className="h-24 text-center" + > + No results. + </TableCell> + </TableRow> + )} + </SortableContext> + </TableBody> + </Table> + </DndContext> + </div> + + <div className="flex flex-col gap-2.5"> + <DataTablePagination table={table} /> + </div> + </div> + ) +} + +// 툴바 액션 컴포넌트 (incoterms와 동일한 스타일) +function NumberTypeConfigsTableToolbarActions({ + table, + onSuccess, + selectedNumberType, + configsData +}: { + table: any + onSuccess?: () => void + selectedNumberType: number | null + configsData: NumberTypeConfig[] +}) { + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [formData, setFormData] = React.useState({ codeGroupId: "", description: "", remark: "" }) + const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([]) + const [allOptions, setAllOptions] = React.useState<{ id: string; name: string }[]>([]) + + const loadCodeGroups = React.useCallback(async () => { + try { + console.log("NumberTypeConfigsTableToolbarActions: Code Groups 로딩 시작") + console.log("NumberTypeConfigsTableToolbarActions: getActiveCodeGroups 함수 호출") + const result = await getActiveCodeGroups() + console.log("NumberTypeConfigsTableToolbarActions: getActiveCodeGroups 결과", result) + console.log("NumberTypeConfigsTableToolbarActions: result.success", result.success) + console.log("NumberTypeConfigsTableToolbarActions: result.data", result.data) + console.log("NumberTypeConfigsTableToolbarActions: result.error", result.error) + + if (result.success && result.data) { + console.log("NumberTypeConfigsTableToolbarActions: Code Groups 설정", result.data) + + // 이미 추가된 Code Group들을 제외하고 필터링 + const usedCodeGroupIds = configsData.map(config => config.codeGroupId) + const availableCodeGroups = result.data.filter(codeGroup => + !usedCodeGroupIds.includes(codeGroup.id) + ) + + console.log("NumberTypeConfigsTableToolbarActions: 사용된 Code Group IDs", usedCodeGroupIds) + console.log("NumberTypeConfigsTableToolbarActions: 사용 가능한 Code Groups", availableCodeGroups) + + setCodeGroups(availableCodeGroups) + } else { + console.error("NumberTypeConfigsTableToolbarActions: Code Groups 로딩 실패", result.error) + } + } catch (error) { + console.error("Error loading code groups:", error) + console.error("Error details:", error) + } + }, [configsData]) + + + + React.useEffect(() => { + loadCodeGroups() + }, [loadCodeGroups]) + + // Code Groups를 옵션 목록으로 만드는 함수 + const combineOptions = React.useCallback(() => { + const codeGroupOptions = codeGroups.map(cg => ({ + id: `cg_${cg.id}`, + name: cg.description + })) + + setAllOptions(codeGroupOptions) + }, [codeGroups]) + + // Code Groups가 변경될 때마다 옵션 목록 업데이트 + React.useEffect(() => { + combineOptions() + }, [combineOptions]) + + // 다이얼로그가 열릴 때마다 Code Groups 다시 로드 + React.useEffect(() => { + if (isAddDialogOpen) { + loadCodeGroups() + } + }, [isAddDialogOpen, loadCodeGroups, configsData]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!selectedNumberType || !formData.codeGroupId) { + toast.error("필수 필드를 모두 입력해주세요.") + return + } + + const sdq = getNextSdq() + setIsLoading(true) + + try { + // Code Group ID 추출 + const codeGroupId = parseInt(formData.codeGroupId.replace('cg_', '')) + + const result = await createNumberTypeConfig({ + documentNumberTypeId: selectedNumberType, + codeGroupId: codeGroupId, + documentClassId: null, + sdq: sdq, + description: formData.description || undefined, + remark: formData.remark || undefined, + }) + + if (result.success) { + toast.success("Number Type Config가 성공적으로 추가되었습니다.") + setIsAddDialogOpen(false) + setFormData({ codeGroupId: "", description: "", remark: "" }) + onSuccess?.() + } else { + toast.error(result.error || "추가에 실패했습니다.") + } + } catch (error) { + console.error("Error creating number type config:", error) + toast.error("추가 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const getNextSdq = () => { + if (configsData.length === 0) return 1 + const maxSdq = Math.max(...configsData.map(config => config.sdq)) + return maxSdq + 1 + } + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteNumberTypeConfigsDialog + configs={table + .getFilteredSelectedRowModel() + .rows.map((row: any) => row.original)} + onSuccess={() => { + table.toggleAllRowsSelected(false); + onSuccess?.(); + }} + /> + ) : null} + + <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" disabled={!selectedNumberType || codeGroups.length === 0}> + <Plus className="mr-2 h-4 w-4" /> + Add + </Button> + </DialogTrigger> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Number Type Config 추가</DialogTitle> + <DialogDescription> + 새로운 구성 요소를 추가합니다. 필수 정보를 입력해주세요. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + </DialogDescription> + </DialogHeader> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="grid gap-4 py-2"> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="codeGroup" className="text-right"> + Code Group <span className="text-red-500">*</span> + </Label> + <div className="col-span-3"> + <Select + value={formData.codeGroupId} + onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))} + > + <SelectTrigger> + <SelectValue placeholder="Code Group 선택" /> + </SelectTrigger> + <SelectContent> + {allOptions.length > 0 ? ( + allOptions.map((option) => ( + <SelectItem key={option.id} value={option.id}> + {option.name} + </SelectItem> + )) + ) : ( + <div className="px-2 py-1.5 text-sm text-muted-foreground"> + 사용 가능한 옵션이 없습니다. + </div> + )} + </SelectContent> + </Select> + </div> + </div> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="description" className="text-right"> + Description + </Label> + <div className="col-span-3"> + <Input + id="description" + value={formData.description} + onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="예: PROJECT NO" + /> + </div> + </div> + <div className="grid grid-cols-4 items-center gap-4"> + <Label htmlFor="remark" className="text-right"> + Remark + </Label> + <div className="col-span-3"> + <Textarea + id="remark" + value={formData.remark} + onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))} + placeholder="비고 사항" + rows={3} + /> + </div> + </div> + </div> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setIsAddDialogOpen(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + </div> + ) +} + +export function NumberTypeConfigsTable({ promises }: NumberTypeConfigsTableProps) { + const rawData = React.use(promises!) + const [selectedNumberType, setSelectedNumberType] = React.useState<number | null>(null) + const [configsData, setConfigsData] = React.useState<NumberTypeConfig[]>([]) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<NumberTypeConfig> | null>(null) + + // 상태 변화 추적 + React.useEffect(() => { + console.log("selectedNumberType changed:", selectedNumberType) + }, [selectedNumberType]) + + React.useEffect(() => { + console.log("configsData changed:", configsData) + console.log("configsData length:", configsData.length) + }, [configsData]) + + // 드래그 종료 핸들러 + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event + + if (active.id !== over?.id) { + const oldIndex = configsData.findIndex(config => config.id.toString() === active.id) + const newIndex = configsData.findIndex(config => config.id.toString() === over?.id) + + if (oldIndex !== -1 && newIndex !== -1) { + const newConfigsData = arrayMove(configsData, oldIndex, newIndex) + + // 순서 업데이트 + const updatedConfigsData = newConfigsData.map((config, index) => ({ + ...config, + sdq: index + 1 + })) + + setConfigsData(updatedConfigsData) + + // 서버에 순서 업데이트 + try { + for (const config of updatedConfigsData) { + await updateNumberTypeConfig({ + id: config.id, + codeGroupId: config.codeGroupId, + documentClassId: config.documentClassId, + sdq: config.sdq, + description: config.description || undefined, + remark: config.remark || undefined, + }) + } + toast.success("순서가 성공적으로 변경되었습니다.") + } catch (error) { + console.error("Error updating order:", error) + toast.error("순서 변경 중 오류가 발생했습니다.") + // 에러 시 원래 데이터로 복원 + if (selectedNumberType) { + const refreshResult = await getNumberTypeConfigs(selectedNumberType) + if (refreshResult.success && refreshResult.data) setConfigsData(refreshResult.data) + } + } + } + } + } + + // Number Type Configs를 가져오는 함수 + const fetchConfigs = React.useCallback(async (numberTypeId: number) => { + try { + const result = await getNumberTypeConfigs(numberTypeId) + if (result.success && result.data) { + console.log("Configs data loaded:", result.data) + setConfigsData(result.data) + } + } catch (error) { + console.error("Error loading configs:", error) + } + }, []) + + // Number Types 데이터 로드 및 첫 번째 타입의 configs 데이터 로드 + React.useEffect(() => { + const loadData = async () => { + console.log("useEffect triggered - rawData:", !!rawData) + + try { + const result = rawData[0] + console.log("Raw data result:", result) + + if (result.data && result.data.length > 0) { + const firstNumberTypeId = result.data[0].id + console.log("Setting first number type ID:", firstNumberTypeId) + setSelectedNumberType(firstNumberTypeId) + + // 첫 번째 타입의 configs 데이터도 바로 로드 + console.log("Loading configs for first number type:", firstNumberTypeId) + const configsResult = await getNumberTypeConfigs(firstNumberTypeId) + console.log("Configs result received:", configsResult) + + if (configsResult && configsResult.success && configsResult.data) { + console.log("Setting configs data:", configsResult.data) + setConfigsData(configsResult.data) + } else { + console.log("Configs result not successful or no data:", configsResult) + setConfigsData([]) + } + } + } catch (error) { + console.error("Error in loadData:", error) + } + } + + loadData() + }, [rawData]) + + // selectedNumberType이 변경될 때 configs 데이터 로드 (첫 번째 로드 이후) + React.useEffect(() => { + console.log("Second useEffect triggered - selectedNumberType:", selectedNumberType) + + const loadConfigs = async () => { + if (selectedNumberType) { + console.log("Loading configs for selectedNumberType:", selectedNumberType) + + try { + const configsResult = await getNumberTypeConfigs(selectedNumberType) + console.log("Configs result received:", configsResult) + + if (configsResult && configsResult.success && configsResult.data) { + console.log("Setting configs data:", configsResult.data) + setConfigsData(configsResult.data) + } else { + console.log("Configs result not successful or no data:", configsResult) + setConfigsData([]) + } + } catch (error) { + console.error("Error loading configs:", error) + setConfigsData([]) + } + } else { + console.log("selectedNumberType is null, skipping configs load") + } + } + + loadConfigs() + }, [selectedNumberType]) + + // advanced filter fields 정의 + const advancedFilterFields: DataTableAdvancedFilterField<NumberTypeConfig>[] = [ + { id: "codeGroupName", label: "Code Group", type: "text" }, + { id: "sdq", label: "순서", type: "number" }, + { id: "description", label: "Description", type: "text" }, + { id: "remark", label: "Remark", type: "text" }, + { id: "isActive", label: "상태", type: "select", options: [ { label: "활성", value: "true"}, { label: "비활성", value: "false" }] }, + ] + + // useDataTable 적용 + const columns = React.useMemo(() => { + const cols = getColumns({ setRowAction }) + console.log("Generated columns:", cols.map(col => (col as any).id || (col as any).accessorKey)) + return cols + }, [setRowAction]) + + // 클라이언트 사이드 정렬을 위한 정렬된 데이터 + const sortedConfigsData = React.useMemo(() => { + return [...configsData].sort((a, b) => { + // 기본적으로 sdq 순으로 정렬 + return a.sdq - b.sdq + }) + }, [configsData]) + + const { table } = useDataTable({ + data: sortedConfigsData, + columns: columns, + pageCount: 1, + enablePinning: true, + enableAdvancedFilter: true, + manualSorting: false, + initialState: { + sorting: [{ id: "sdq", desc: false }], + // columnPinning: { right: ["actions"] }, // 일시적으로 제거 + }, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }) + + const refreshData = React.useCallback(async () => { + if (selectedNumberType) { + try { + const result = await getNumberTypeConfigs(selectedNumberType) + if (result.success && result.data) setConfigsData(result.data) + } catch (error) { + console.error("Error refreshing data:", error) + } + } + }, [selectedNumberType]) + + return ( + <> + {/* Number Type 선택 */} + <div className="mb-6"> + <label className="text-sm font-medium mb-2 block">Number Type 선택</label> + <div className="flex gap-2"> + {rawData[0]?.data && rawData[0].data.length > 0 ? ( + rawData[0].data.map((numberType) => ( + <button + key={numberType.id} + onClick={() => { + setSelectedNumberType(numberType.id) + fetchConfigs(numberType.id) + }} + className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${ + selectedNumberType === numberType.id + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground hover:bg-muted/80" + }`} + > + {numberType.name} + </button> + )) + ) : ( + <div className="px-4 py-2 text-sm text-muted-foreground"> + Number Type을 불러오는 중... + </div> + )} + </div> + </div> + + {/* 선택된 Number Type 정보 및 테이블: 조건부 렌더링 */} + {selectedNumberType && rawData[0]?.data && rawData[0].data.length > 0 && ( + <> + <DragDropTable + table={table} + data={configsData} + onDragEnd={handleDragEnd} + > + <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields}> + <NumberTypeConfigsTableToolbarActions + table={table} + onSuccess={refreshData} + selectedNumberType={selectedNumberType} + configsData={configsData} + /> + </DataTableAdvancedToolbar> + </DragDropTable> + </> + )} + + {/* 삭제/수정 다이얼로그 */} + <DeleteNumberTypeConfigsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + configs={rowAction?.row ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={refreshData} + /> + <NumberTypeConfigsEditDialog + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + data={rowAction?.row?.original ?? null} + existingConfigs={configsData} + onSuccess={refreshData} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-type-configs/validation.ts b/lib/docu-list-rule/number-type-configs/validation.ts new file mode 100644 index 00000000..2b3aaa2d --- /dev/null +++ b/lib/docu-list-rule/number-type-configs/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 searchParamsNumberTypeConfigsCache = 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 diff --git a/lib/docu-list-rule/number-types/service.ts b/lib/docu-list-rule/number-types/service.ts new file mode 100644 index 00000000..8eaf19c7 --- /dev/null +++ b/lib/docu-list-rule/number-types/service.ts @@ -0,0 +1,251 @@ +"use server" + +import { revalidatePath } from "next/cache" +import db from "@/db/db" +import { documentNumberTypes, documentNumberTypeConfigs } from "@/db/schema/docu-list-rule" +import { eq, sql } from "drizzle-orm" +import { unstable_noStore } from "next/cache" + +// Number Types 목록 조회 +export async function getNumberTypes(input: { + page: number + perPage: number + search?: string + sort?: Array<{ id: string; desc: boolean }> + filters?: Array<{ id: string; value: string }> + joinOperator?: "and" | "or" + flags?: string[] + numberTypeId?: string + description?: string + isActive?: string +}) { + unstable_noStore() + + try { + const { page, perPage, sort, search } = input + const offset = (page - 1) * perPage + + // 기본 조건 + let whereConditions = sql`1=1` + + // 검색 조건 + if (search) { + const searchTerm = `%${search}%` + whereConditions = sql`${whereConditions} AND ( + ${documentNumberTypes.name} ILIKE ${searchTerm} OR + ${documentNumberTypes.description} ILIKE ${searchTerm} + )` + } + + // 정렬 + let orderBy = sql`${documentNumberTypes.createdAt} DESC` + if (sort && sort.length > 0) { + const sortField = sort[0] + const direction = sortField.desc ? sql`DESC` : sql`ASC` + + switch (sortField.id) { + case "id": + orderBy = sql`${documentNumberTypes.id} ${direction}` + break + case "name": + orderBy = sql`${documentNumberTypes.name} ${direction}` + break + case "description": + orderBy = sql`${documentNumberTypes.description} ${direction}` + break + case "isActive": + orderBy = sql`${documentNumberTypes.isActive} ${direction}` + break + case "createdAt": + orderBy = sql`${documentNumberTypes.createdAt} ${direction}` + break + default: + orderBy = sql`${documentNumberTypes.createdAt} DESC` + } + } + + // 데이터 조회 + const data = await db + .select({ + id: documentNumberTypes.id, + name: documentNumberTypes.name, + description: documentNumberTypes.description, + isActive: documentNumberTypes.isActive, + createdAt: documentNumberTypes.createdAt, + updatedAt: documentNumberTypes.updatedAt, + }) + .from(documentNumberTypes) + .where(whereConditions) + .orderBy(orderBy) + .limit(perPage) + .offset(offset) + + // 총 개수 조회 + const [{ count: total }] = await db + .select({ count: sql`count(*)` }) + .from(documentNumberTypes) + .where(whereConditions) + + const pageCount = Math.ceil(Number(total) / perPage) + + return { + success: true, + data, + pageCount, + } + } catch (error) { + console.error("Error fetching number types:", error) + return { + success: false, + error: "Failed to fetch number types", + data: [], + pageCount: 0, + } + } +} + +// Number Type 생성 +export async function createNumberType(input: { + name: string + description?: string + isActive?: boolean +}) { + try { + // 중복 이름 체크 + const existing = await db + .select({ id: documentNumberTypes.id }) + .from(documentNumberTypes) + .where(eq(documentNumberTypes.name, input.name)) + .limit(1) + + if (existing.length > 0) { + return { + success: false, + error: "Number Type with this name already exists" + } + } + + const [newNumberType] = await db + .insert(documentNumberTypes) + .values({ + name: input.name, + description: input.description, + isActive: input.isActive ?? true, + }) + .returning({ id: documentNumberTypes.id }) + + revalidatePath("/evcp/docu-list-rule/number-types") + + return { + success: true, + data: newNumberType, + message: "Number Type created successfully" + } + } catch (error) { + console.error("Error creating number type:", error) + return { + success: false, + error: "Failed to create number type" + } + } +} + +// Number Type 수정 +export async function updateNumberType(input: { + id: number + name: string + description?: string + isActive?: boolean +}) { + try { + // 다른 Number Type에서 같은 이름 사용하는지 체크 + const existing = await db + .select({ id: documentNumberTypes.id }) + .from(documentNumberTypes) + .where(sql`${documentNumberTypes.name} = ${input.name} AND ${documentNumberTypes.id} != ${input.id}`) + .limit(1) + + if (existing.length > 0) { + return { + success: false, + error: "Number Type with this name already exists" + } + } + + const [updatedNumberType] = await db + .update(documentNumberTypes) + .set({ + name: input.name, + description: input.description, + isActive: input.isActive, + updatedAt: new Date(), + }) + .where(eq(documentNumberTypes.id, input.id)) + .returning({ id: documentNumberTypes.id }) + + if (!updatedNumberType) { + return { + success: false, + error: "Number Type not found" + } + } + + revalidatePath("/evcp/docu-list-rule/number-types") + + return { + success: true, + data: updatedNumberType, + message: "Number Type updated successfully" + } + } catch (error) { + console.error("Error updating number type:", error) + return { + success: false, + error: "Failed to update number type" + } + } +} + +// Number Type 삭제 +export async function deleteNumberType(id: number) { + try { + // 관련된 config가 있는지 확인 + const relatedConfigs = await db + .select({ id: documentNumberTypeConfigs.id }) + .from(documentNumberTypeConfigs) + .where(eq(documentNumberTypeConfigs.documentNumberTypeId, id)) + .limit(1) + + if (relatedConfigs.length > 0) { + return { + success: false, + error: "Cannot delete Number Type with existing configurations" + } + } + + const [deletedNumberType] = await db + .delete(documentNumberTypes) + .where(eq(documentNumberTypes.id, id)) + .returning({ id: documentNumberTypes.id }) + + if (!deletedNumberType) { + return { + success: false, + error: "Number Type not found" + } + } + + revalidatePath("/evcp/docu-list-rule/number-types") + + return { + success: true, + message: "Number Type deleted successfully" + } + } catch (error) { + console.error("Error deleting number type:", error) + return { + success: false, + error: "Failed to delete number type" + } + } +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-types/table/delete-number-types-dialog.tsx b/lib/docu-list-rule/number-types/table/delete-number-types-dialog.tsx new file mode 100644 index 00000000..8d1bc679 --- /dev/null +++ b/lib/docu-list-rule/number-types/table/delete-number-types-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 { deleteNumberType } from "../service" +import { documentNumberTypes } from "@/db/schema/docu-list-rule" + +interface DeleteNumberTypesDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + numberTypes: typeof documentNumberTypes.$inferSelect[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteNumberTypesDialog({ + numberTypes, + showTrigger = true, + onSuccess, + ...props +}: DeleteNumberTypesDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + for (const numberType of numberTypes) { + const result = await deleteNumberType(numberType.id) + if (!result.success) { + toast.error(`Number Type 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("Number Type이 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("Number Type 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({numberTypes.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{numberTypes.length}</span> + 개의 Number Type을 서버에서 영구적으로 삭제합니다. + </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" /> + 삭제 ({numberTypes.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{numberTypes.length}</span> + 개의 Number Type을 서버에서 영구적으로 삭제합니다. + </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/number-types/table/number-type-add-dialog.tsx b/lib/docu-list-rule/number-types/table/number-type-add-dialog.tsx new file mode 100644 index 00000000..78c3ec37 --- /dev/null +++ b/lib/docu-list-rule/number-types/table/number-type-add-dialog.tsx @@ -0,0 +1,125 @@ +"use client" + +import * as React from "react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Plus } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { toast } from "sonner" +import { createNumberType } from "../service" + +interface NumberTypeAddDialogProps { + onSuccess: () => void +} + +export function NumberTypeAddDialog({ onSuccess }: NumberTypeAddDialogProps) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [formData, setFormData] = useState({ + name: "", + description: "", + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.name.trim()) { + toast.error("Name은 필수 입력 항목입니다.") + return + } + + setLoading(true) + try { + const result = await createNumberType({ + name: formData.name.trim(), + description: formData.description.trim() || undefined, + }) + + if (result.success) { + toast.success("Number Type이 생성되었습니다.") + setFormData({ name: "", description: "" }) + setOpen(false) + onSuccess() + } else { + toast.error(result.error || "Number Type 생성에 실패했습니다.") + } + } catch (error) { + console.error("Number Type 생성 실패:", error) + toast.error("Number Type 생성에 실패했습니다.") + } finally { + setLoading(false) + } + } + + const handleCancel = () => { + setFormData({ name: "", description: "" }) + 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>Add Number Type</DialogTitle> + <DialogDescription> + 새로운 Number Type을 추가합니다. + </DialogDescription> + </DialogHeader> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="name">Name *</Label> + <Input + id="name" + value={formData.name} + onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="Enter name" + required + /> + </div> + <div className="space-y-2"> + <Label htmlFor="description">Description</Label> + <Input + id="description" + value={formData.description} + onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="Enter description" + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={loading} + > + Cancel + </Button> + <Button + type="submit" + disabled={loading} + > + {loading ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-types/table/number-type-edit-sheet.tsx b/lib/docu-list-rule/number-types/table/number-type-edit-sheet.tsx new file mode 100644 index 00000000..f4066cea --- /dev/null +++ b/lib/docu-list-rule/number-types/table/number-type-edit-sheet.tsx @@ -0,0 +1,159 @@ +"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 { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { updateNumberType } from "../service" +import { documentNumberTypes } from "@/db/schema/docu-list-rule" + +const updateNumberTypeSchema = z.object({ + name: z.string().min(1, "이름은 필수입니다."), + description: z.string().optional(), +}) + +type UpdateNumberTypeSchema = z.infer<typeof updateNumberTypeSchema> + +interface NumberTypeEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + data: typeof documentNumberTypes.$inferSelect | null + onSuccess: () => void +} + +export function NumberTypeEditSheet({ + open, + onOpenChange, + data, + onSuccess, +}: NumberTypeEditSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateNumberTypeSchema>({ + resolver: zodResolver(updateNumberTypeSchema), + defaultValues: { + name: data?.name ?? "", + description: data?.description ?? "", + }, + mode: "onChange" + }) + + React.useEffect(() => { + if (data) { + form.reset({ + name: data.name, + description: data.description || "", + }) + } + }, [data, form]) + + async function onSubmit(input: UpdateNumberTypeSchema) { + if (!data) return + + startUpdateTransition(async () => { + try { + const result = await updateNumberType({ + id: data.id, + name: input.name, + description: input.description, + }) + + if (result.success) { + toast.success("Number Type이 성공적으로 수정되었습니다.") + onSuccess() + onOpenChange(false) + } else { + toast.error(result.error || "수정에 실패했습니다.") + } + } catch (error) { + console.error("Update error:", error) + toast.error("Number Type 수정 중 오류가 발생했습니다.") + } + }) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Number Type 수정</SheetTitle> + <SheetDescription> + Number Type 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>이름</FormLabel> + <FormControl> + <Input {...field} placeholder="Number Type 이름" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Input {...field} placeholder="설명" /> + </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/number-types/table/number-types-table-columns.tsx b/lib/docu-list-rule/number-types/table/number-types-table-columns.tsx new file mode 100644 index 00000000..93361b93 --- /dev/null +++ b/lib/docu-list-rule/number-types/table/number-types-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 { documentNumberTypes } from "@/db/schema/docu-list-rule" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentNumberTypes.$inferSelect> | null>> +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof documentNumberTypes.$inferSelect>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<typeof documentNumberTypes.$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 documentNumberTypes.$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 documentNumberTypes.$inferSelect>[] = [ + { + accessorKey: "name", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="이름" /> + ), + meta: { + excelHeader: "이름", + type: "text", + }, + cell: ({ row }) => row.getValue("name") ?? "", + 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/number-types/table/number-types-table-toolbar.tsx b/lib/docu-list-rule/number-types/table/number-types-table-toolbar.tsx new file mode 100644 index 00000000..306a3d74 --- /dev/null +++ b/lib/docu-list-rule/number-types/table/number-types-table-toolbar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DeleteNumberTypesDialog } from "./delete-number-types-dialog" +import { NumberTypeAddDialog } from "./number-type-add-dialog" +import { documentNumberTypes } from "@/db/schema/docu-list-rule" + +interface NumberTypesTableToolbarActionsProps { + table: Table<typeof documentNumberTypes.$inferSelect> + onSuccess?: () => void +} + +export function NumberTypesTableToolbarActions({ table, onSuccess }: NumberTypesTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteNumberTypesDialog + numberTypes={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => { + table.toggleAllRowsSelected(false) + onSuccess?.() + }} + /> + ) : null} + + <NumberTypeAddDialog onSuccess={onSuccess || (() => {})} /> + + {/** 3) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "number-types-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-types/table/number-types-table.tsx b/lib/docu-list-rule/number-types/table/number-types-table.tsx new file mode 100644 index 00000000..5d25c4b5 --- /dev/null +++ b/lib/docu-list-rule/number-types/table/number-types-table.tsx @@ -0,0 +1,88 @@ +"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 { getNumberTypes } from "../service" +import { getColumns } from "./number-types-table-columns" +import { NumberTypeEditSheet } from "./number-type-edit-sheet" +import { DeleteNumberTypesDialog } from "./delete-number-types-dialog" +import { NumberTypesTableToolbarActions } from "./number-types-table-toolbar" +import { documentNumberTypes } from "@/db/schema/docu-list-rule" + +interface NumberTypesTableProps { + promises?: Promise<[{ data: typeof documentNumberTypes.$inferSelect[]; pageCount: number }]> +} + +export function NumberTypesTable({ promises }: NumberTypesTableProps) { + const rawData = React.use(promises!) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentNumberTypes.$inferSelect> | null>(null) + + const refreshData = React.useCallback(() => { + window.location.reload() + }, []) + + const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction]) + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<typeof documentNumberTypes.$inferSelect>[] = [ + { id: "name", label: "이름", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { + id: "isActive", label: "상태", type: "select", options: [ + { label: "활성", value: "true" }, + { label: "비활성", value: "false" }, + ] + }, + { 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} + > + <NumberTypesTableToolbarActions table={table} onSuccess={refreshData} /> + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteNumberTypesDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + numberTypes={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + refreshData() + }} + /> + + <NumberTypeEditSheet + 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/number-types/validation.ts b/lib/docu-list-rule/number-types/validation.ts new file mode 100644 index 00000000..72e2921d --- /dev/null +++ b/lib/docu-list-rule/number-types/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 searchParamsNumberTypesCache = 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 diff --git a/lib/docu-list-rule/types.ts b/lib/docu-list-rule/types.ts new file mode 100644 index 00000000..a5277132 --- /dev/null +++ b/lib/docu-list-rule/types.ts @@ -0,0 +1,50 @@ +// docu-list-rule 모듈 공통 타입 정의 + +export interface NumberTypeConfig { + id: number + documentNumberTypeId: number + codeGroupId: number | null + documentClassId: number | null + sdq: number + description: string | null + remark: string | null + isActive: boolean | null + createdAt: Date + updatedAt: Date + codeGroupName: string | null + codeGroupControlType: string | null + documentClassName: string | null + documentClassDescription: string | null +} + +export interface CodeGroup { + id: number + groupId: string + description: string + codeFormat: string | null + expressions: string | null + controlType: string + isActive: boolean + createdAt: Date + updatedAt: Date +} + +export interface DocumentClass { + id: number + code: string + value: string + description: string | null + codeGroupId: number | null + isActive: boolean + createdAt: Date + updatedAt: Date +} + +export interface NumberType { + id: number + name: string + description: string | null + isActive: boolean + createdAt: Date + updatedAt: Date +}
\ No newline at end of file diff --git a/lib/docu-list-rule/utils.ts b/lib/docu-list-rule/utils.ts new file mode 100644 index 00000000..ddeb5e6d --- /dev/null +++ b/lib/docu-list-rule/utils.ts @@ -0,0 +1,60 @@ +// docu-list-rule 모듈 공통 유틸리티 함수들 + +/** + * Code Group ID에서 다음 번호를 생성하는 함수 + * DOC_CLASS는 제외하고 계산 + */ +export function generateNextCodeGroupId(lastGroupId: string): string { + if (!lastGroupId.startsWith('GROUP')) { + return 'GROUP001' + } + + const numberPart = lastGroupId.substring(5) + const nextNumber = parseInt(numberPart) + 1 + return `GROUP${nextNumber.toString().padStart(3, '0')}` +} + +/** + * Document Class Code에서 다음 번호를 생성하는 함수 + */ +export function generateNextDocumentClassCode(lastCode: string): string { + if (!lastCode.startsWith('DOC_CLASS_')) { + return 'DOC_CLASS_001' + } + + const numberPart = lastCode.substring(10) + const nextNumber = parseInt(numberPart) + 1 + return `DOC_CLASS_${nextNumber.toString().padStart(3, '0')}` +} + +/** + * Number Type Config에서 다음 SDQ를 생성하는 함수 + */ +export function generateNextSdq(configs: Array<{ sdq: number }>): number { + if (configs.length === 0) { + return 1 + } + + const maxSdq = Math.max(...configs.map(config => config.sdq)) + return maxSdq + 1 +} + +/** + * 활성화된 항목만 필터링하는 함수 + */ +export function filterActiveItems<T extends { isActive: boolean }>(items: T[]): T[] { + return items.filter(item => item.isActive) +} + +/** + * 날짜를 포맷팅하는 함수 + */ +export function formatDate(date: Date): string { + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }).format(date) +}
\ No newline at end of file |
