diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 09:19:42 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 09:19:42 +0000 |
| commit | 50ae0b8f02c034e60d4cbb504620dfa1575a836f (patch) | |
| tree | 24c661a0c7354e15ad56e2bded4d300bd7fd2b41 /lib/docu-list-rule/combo-box-settings/table | |
| parent | 738f956aa61264ffa761e30398eca23393929f8c (diff) | |
(박서영) 설계 document Numbering Rule 개발-최겸 업로드
Diffstat (limited to 'lib/docu-list-rule/combo-box-settings/table')
13 files changed, 2187 insertions, 0 deletions
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 |
