diff options
| author | 0-Zz-ang <s1998319@gmail.com> | 2025-08-04 14:59:15 +0900 |
|---|---|---|
| committer | 0-Zz-ang <s1998319@gmail.com> | 2025-08-04 14:59:15 +0900 |
| commit | 59b5715ebb3e1fd7bd4eb02ce50399715734f865 (patch) | |
| tree | 39ccd16482c1b90b6583ead73384822157254d88 /lib | |
| parent | f0213de0d2fb5fcb931b3ddaddcbb6581cab5d28 (diff) | |
(박서영) docu-list-rule detail sheet 컴포넌트 추가 및 검색 필터 기능 오류 수정
Diffstat (limited to 'lib')
46 files changed, 913 insertions, 1998 deletions
diff --git a/lib/docu-list-rule/code-groups/service.ts b/lib/docu-list-rule/code-groups/service.ts index 2c30cedb..c99588a5 100644 --- a/lib/docu-list-rule/code-groups/service.ts +++ b/lib/docu-list-rule/code-groups/service.ts @@ -2,7 +2,7 @@ import { revalidatePath } from "next/cache" import db from "@/db/db" -import { codeGroups, comboBoxSettings, documentClasses } from "@/db/schema/docu-list-rule" +import { codeGroups, comboBoxSettings } from "@/db/schema/docu-list-rule" import { eq, sql, count } from "drizzle-orm" import { unstable_noStore } from "next/cache" @@ -64,7 +64,7 @@ export async function getCodeGroups(input: any) { } // 정렬 (안전한 필드 체크 적용) - let orderBy = sql`${codeGroups.createdAt} DESC` + let orderBy = sql`${codeGroups.groupId} ASC` if (input.sort && input.sort.length > 0) { const sortField = input.sort[0] // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 @@ -241,11 +241,6 @@ export async function deleteCodeGroup(id: number) { .where(eq(comboBoxSettings.codeGroupId, id)) } - // Document Class가 연결된 경우 Document Class도 삭제 - await db - .delete(documentClasses) - .where(eq(documentClasses.codeGroupId, id)) - // Code Group 삭제 await db .delete(codeGroups) @@ -253,7 +248,6 @@ export async function deleteCodeGroup(id: number) { 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, 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 index 660adfed..bf044f1a 100644 --- 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 @@ -19,6 +19,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -39,8 +40,8 @@ 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(숫자)만 입력 가능합니다."), + return /^[ANX]*$/.test(val); + }, "Code Format은 A(영어 대문자), N(숫자), X(영어/숫자)만 입력 가능합니다."), controlType: z.string().min(1, "Control Type은 필수입니다."), }) @@ -80,6 +81,8 @@ export function CodeGroupsAddDialog({ onSuccess }: CodeGroupsAddDialogProps) { expression += `[A-Z]{${count}}` } else if (currentChar === 'N') { expression += `[0-9]{${count}}` + } else if (currentChar === 'X') { + expression += `[A-Z0-9]{${count}}` } currentChar = codeFormat[i] count = 1 @@ -91,6 +94,8 @@ export function CodeGroupsAddDialog({ onSuccess }: CodeGroupsAddDialogProps) { expression += `[A-Z]{${count}}` } else if (currentChar === 'N') { expression += `[0-9]{${count}}` + } else if (currentChar === 'X') { + expression += `[A-Z0-9]{${count}}` } expression += '$' @@ -177,11 +182,14 @@ export function CodeGroupsAddDialog({ onSuccess }: CodeGroupsAddDialogProps) { <FormLabel>Code Format</FormLabel> <FormControl> <Input - placeholder="예: AANNN (A:영어대문자, N:숫자)" + placeholder="예: AANNN (A:영어대문자, N:숫자, X:영어/숫자)" {...field} onBlur={() => form.trigger('codeFormat')} /> </FormControl> + <FormDescription> + A: 영어 대문자, N: 숫자, X: 영어 대문자 또는 숫자 + </FormDescription> <FormMessage /> </FormItem> )} 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 index 28aebd54..74ebc05a 100644 --- 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 @@ -19,6 +19,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -41,8 +42,8 @@ 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(숫자)만 입력 가능합니다."), + return /^[ANX]*$/.test(val); + }, "Code Format은 A(영어 대문자), N(숫자), X(영어/숫자)만 입력 가능합니다."), controlType: z.string().min(1, "Control Type은 필수입니다."), isActive: z.boolean().default(true), }) @@ -92,6 +93,8 @@ export function CodeGroupsEditSheet({ expression += `[A-Z]{${count}}` } else if (currentChar === 'N') { expression += `[0-9]{${count}}` + } else if (currentChar === 'X') { + expression += `[A-Z0-9]{${count}}` } currentChar = codeFormat[i] count = 1 @@ -103,6 +106,8 @@ export function CodeGroupsEditSheet({ expression += `[A-Z]{${count}}` } else if (currentChar === 'N') { expression += `[0-9]{${count}}` + } else if (currentChar === 'X') { + expression += `[A-Z0-9]{${count}}` } expression += '$' @@ -183,11 +188,14 @@ export function CodeGroupsEditSheet({ <FormLabel>Code Format</FormLabel> <FormControl> <Input - placeholder="예: AANNN (A:영어대문자, N:숫자)" + placeholder="예: AANNN (A:영어대문자, N:숫자, X:영어/숫자)" {...field} onBlur={() => form.trigger('codeFormat')} /> </FormControl> + <FormDescription> + A: 영어 대문자, N: 숫자, X: 영어 대문자 또는 숫자 + </FormDescription> <FormMessage /> </FormItem> )} 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 index c10d3445..8873c34c 100644 --- a/lib/docu-list-rule/code-groups/table/code-groups-table.tsx +++ b/lib/docu-list-rule/code-groups/table/code-groups-table.tsx @@ -48,8 +48,6 @@ export function CodeGroupsTable({ promises }: CodeGroupsTableProps) { id: "controlType", label: "Control Type", type: "select", options: [ { label: "Textbox", value: "textbox" }, { label: "Combobox", value: "combobox" }, - { label: "Date", value: "date" }, - { label: "Number", value: "number" }, ] }, { @@ -69,7 +67,6 @@ export function CodeGroupsTable({ promises }: CodeGroupsTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, }, getRowId: (originalRow) => String(originalRow.groupId), diff --git a/lib/docu-list-rule/code-groups/validation.ts b/lib/docu-list-rule/code-groups/validation.ts index 90e06d0c..9745841c 100644 --- a/lib/docu-list-rule/code-groups/validation.ts +++ b/lib/docu-list-rule/code-groups/validation.ts @@ -17,7 +17,7 @@ export const searchParamsCodeGroupsCache = createSearchParamsCache({ page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), sort: getSortingStateParser<typeof codeGroups.$inferSelect>().withDefault([ - { id: "createdAt", desc: true }, + { id: "groupId", desc: false }, ]), groupId: parseAsString.withDefault(""), diff --git a/lib/docu-list-rule/combo-box-settings/service.ts b/lib/docu-list-rule/combo-box-settings/service.ts index 2c5ee42b..80c1942d 100644 --- a/lib/docu-list-rule/combo-box-settings/service.ts +++ b/lib/docu-list-rule/combo-box-settings/service.ts @@ -74,7 +74,7 @@ export async function getComboBoxCodeGroups(input: { } // 정렬 (안전한 필드 체크 적용) - let orderBy = sql`${codeGroups.createdAt} DESC` + let orderBy = sql`${codeGroups.groupId} ASC` if (sort && sort.length > 0) { const sortField = sort[0] // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 @@ -155,7 +155,7 @@ export async function getComboBoxOptions(codeGroupId: number, input?: { } // 정렬 (안전한 필드 체크 적용) - let orderBy = sql`${comboBoxSettings.createdAt} DESC` + let orderBy = sql`${comboBoxSettings.code} ASC` if (sort && sort.length > 0) { const sortField = sort[0] // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 @@ -281,6 +281,36 @@ export async function updateComboBoxOption(input: { remark?: string }) { try { + // 현재 수정 중인 항목의 codeGroupId 가져오기 + const currentOption = await db + .select({ codeGroupId: comboBoxSettings.codeGroupId }) + .from(comboBoxSettings) + .where(eq(comboBoxSettings.id, input.id)) + .limit(1) + + if (currentOption.length === 0) { + return { + success: false, + error: "Option not found" + } + } + + // 코드 중복 체크 (현재 수정 중인 항목 제외) + const existingOption = await db + .select({ id: comboBoxSettings.id }) + .from(comboBoxSettings) + .where( + sql`${comboBoxSettings.codeGroupId} = ${currentOption[0].codeGroupId} AND ${comboBoxSettings.code} = ${input.code} AND ${comboBoxSettings.id} != ${input.id}` + ) + .limit(1) + + if (existingOption.length > 0) { + return { + success: false, + error: "이미 존재하는 코드입니다." + } + } + const [updatedOption] = await db .update(comboBoxSettings) .set({ 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 index a5a8af2f..049e2c1a 100644 --- 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 @@ -31,7 +31,7 @@ import { createComboBoxOption } from "../service" const createOptionSchema = z.object({ code: z.string().min(1, "코드는 필수입니다."), - description: z.string().default("-"), + description: z.string().default(""), remark: z.string().optional(), }) @@ -50,18 +50,24 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt resolver: zodResolver(createOptionSchema), defaultValues: { code: "", - description: "-", + description: "", remark: "", }, }) + // 코드 입력 시 자동으로 description에 반영 + const handleCodeChange = (value: string) => { + form.setValue("code", value) + form.setValue("description", value) // 코드값을 description에도 자동 설정 + } + const handleSubmit = (data: CreateOptionSchema) => { startTransition(async () => { try { const result = await createComboBoxOption({ codeGroupId, code: data.code, - description: data.description || "-", + description: data.description || data.code, // description이 비어있으면 code 사용 remark: data.remark, }) @@ -109,7 +115,11 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt <FormItem> <FormLabel>코드</FormLabel> <FormControl> - <Input {...field} placeholder="옵션 코드" /> + <Input + {...field} + placeholder="옵션 코드" + onChange={(e) => handleCodeChange(e.target.value)} + /> </FormControl> <FormMessage /> </FormItem> @@ -120,9 +130,9 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt name="description" render={({ field }) => ( <FormItem> - <FormLabel>값</FormLabel> + <FormLabel>Description</FormLabel> <FormControl> - <Input {...field} placeholder="옵션 값" /> + <Input {...field} placeholder="옵션 설명" /> </FormControl> <FormMessage /> </FormItem> 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 index 0b2a76a4..b62b258e 100644 --- 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 @@ -1,22 +1,20 @@ "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 { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel } from "@tanstack/react-table" +import { DataTableDetail } from "@/components/data-table/data-table-detail" +import { DataTableAdvancedToolbarDetail } from "@/components/data-table/data-table-advanced-toolbar-detail" 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" - +import { getComboBoxOptions } from "@/lib/docu-list-rule/combo-box-settings/service" +import { getColumns } from "@/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-columns" +import { ComboBoxOptionsEditSheet } from "@/lib/docu-list-rule/combo-box-settings/table/combo-box-options-edit-sheet" +import { DeleteComboBoxOptionsDialog } from "@/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-options-dialog" +import { ComboBoxOptionsTableToolbarActions } from "@/lib/docu-list-rule/combo-box-settings/table/combo-box-options-table-toolbar" +import { codeGroups } from "@/db/schema" type ComboBoxOption = { id: number codeGroupId: number @@ -88,10 +86,6 @@ export function ComboBoxOptionsDetailSheet({ const result = await getComboBoxOptions(codeGroup.id, { page: 1, perPage: 10, - search: table.getState().globalFilter || "", - sort: table.getState().sorting, - filters: table.getState().columnFilters, - joinOperator: "and", }) if (result.success && result.data) { const optionsWithIsActive = result.data.map(option => ({ @@ -112,27 +106,25 @@ export function ComboBoxOptionsDetailSheet({ // 고급 필터 필드 설정 const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ - { id: "code", label: "코드", type: "text" }, - { id: "description", label: "값", type: "text" }, - { id: "remark", label: "비고", type: "text" }, + { id: "code", label: "code", type: "text" }, + { id: "remark", label: "remark", type: "text" }, + { id: "updatedAt", label: "updated_at", type: "date" }, ] - const { table } = useDataTable({ + const table = useReactTable({ data: rawData.data as any, columns: columns as any, - pageCount: rawData.pageCount, - enablePinning: true, - enableAdvancedFilter: true, - manualSorting: true, - manualFiltering: true, - manualPagination: true, // 수동 페이징 활성화 + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, + sorting: [{ id: "code", desc: false }], + pagination: { + pageSize: 10, + }, }, getRowId: (originalRow) => String((originalRow as any).id), - shallow: false, - clearOnDefault: true, }) if (!codeGroup) return null @@ -144,7 +136,7 @@ export function ComboBoxOptionsDetailSheet({ <div> <h3 className="text-lg font-medium">{codeGroup.description} 옵션 관리</h3> <p className="text-sm text-muted-foreground"> - {codeGroup.groupId}의 Combo Box 옵션들을 관리합니다. + {codeGroup.description}의 Combo Box 옵션들을 관리합니다. </p> </div> </div> @@ -155,12 +147,12 @@ export function ComboBoxOptionsDetailSheet({ onSuccess={refreshData} /> - <DataTable table={table as any}> - <DataTableAdvancedToolbar + <DataTableDetail table={table as any}> + <DataTableAdvancedToolbarDetail table={table as any} filterFields={advancedFilterFields} /> - </DataTable> + </DataTableDetail> <DeleteComboBoxOptionsDialog open={rowAction?.type === "delete"} 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 deleted file mode 100644 index 6459ae14..00000000 --- a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-dialog.tsx +++ /dev/null @@ -1,234 +0,0 @@ -"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 index 5732674e..4ac539d0 100644 --- 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 @@ -5,10 +5,17 @@ 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, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Form, FormControl, FormField, @@ -17,19 +24,12 @@ import { 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, "값은 필수입니다."), + code: z.string().min(1, "코드는 필수입니다."), + remark: z.string().optional(), }) type UpdateOptionSchema = z.infer<typeof updateOptionSchema> @@ -63,14 +63,16 @@ export function ComboBoxOptionsEditSheet({ const form = useForm<UpdateOptionSchema>({ resolver: zodResolver(updateOptionSchema), defaultValues: { - value: "", + code: "", + remark: "", }, }) React.useEffect(() => { if (data) { form.reset({ - value: data.description, + code: data.code, + remark: data.remark || "", }) } }, [data, form]) @@ -82,7 +84,9 @@ export function ComboBoxOptionsEditSheet({ try { const result = await updateComboBoxOption({ id: data.id, - value: formData.value, + code: formData.code, + description: data.description, // 기존 description 유지 + remark: formData.remark, }) if (result.success) { @@ -117,12 +121,25 @@ export function ComboBoxOptionsEditSheet({ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} - name="value" + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>code</FormLabel> + <FormControl> + <Input {...field} placeholder="옵션 코드" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="remark" render={({ field }) => ( <FormItem> - <FormLabel>값</FormLabel> + <FormLabel>remark</FormLabel> <FormControl> - <Input {...field} placeholder="옵션 값" /> + <Input {...field} placeholder="비고 (선택사항)" /> </FormControl> <FormMessage /> </FormItem> @@ -133,9 +150,6 @@ export function ComboBoxOptionsEditSheet({ 취소 </Button> <Button type="submit" disabled={isPending || !form.formState.isValid}> - {isPending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} 수정 </Button> </SheetFooter> 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 deleted file mode 100644 index 07b63de5..00000000 --- a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-expandable-row.tsx +++ /dev/null @@ -1,263 +0,0 @@ -"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 index e5780e9e..0e46c0ed 100644 --- 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 @@ -114,40 +114,43 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ComboBo accessorKey: "code", enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="코드" /> + <DataTableColumnHeaderSimple column={column} title="code" /> ), meta: { - excelHeader: "코드", + excelHeader: "code", type: "text", }, cell: ({ row }) => row.getValue("code") ?? "", minSize: 80 }, { - accessorKey: "description", + accessorKey: "remark", enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="값" /> + <DataTableColumnHeaderSimple column={column} title="remark" /> ), meta: { - excelHeader: "값", + excelHeader: "remark", type: "text", }, - cell: ({ row }) => row.getValue("description") ?? "", + cell: ({ row }) => row.getValue("remark") ?? "", minSize: 80 }, { - accessorKey: "remark", + accessorKey: "updatedAt", enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="비고" /> + <DataTableColumnHeaderSimple column={column} title="updated_at" /> ), meta: { - excelHeader: "비고", - type: "text", + excelHeader: "updated_at", + type: "date", }, - cell: ({ row }) => row.getValue("remark") ?? "", - minSize: 80 + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date + return date ? new Date(date).toLocaleDateString('ko-KR') : "" + }, + minSize: 100 } ] 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 deleted file mode 100644 index 77cbea01..00000000 --- a/lib/docu-list-rule/combo-box-settings/table/combo-box-settings-table-toolbar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"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 index f6216363..42ce1a19 100644 --- 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 @@ -1,15 +1,12 @@ "use client" import * as React from "react" -import { useRouter } from "next/navigation" 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 { @@ -17,21 +14,15 @@ interface ComboBoxSettingsTableProps { } export function ComboBoxSettingsTable({ promises }: ComboBoxSettingsTableProps) { - const router = useRouter() + 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(() => { - // 전체 페이지 새로고침 대신 router.refresh() 사용 (성능 개선) - router.refresh() - }, [router]) - // Detail 버튼 클릭 핸들러 - const handleDetail = (codeGroup: typeof codeGroups.$inferSelect) => { + const handleDetail = React.useCallback((codeGroup: typeof codeGroups.$inferSelect) => { setSelectedCodeGroup(codeGroup) setIsDetailSheetOpen(true) - } + }, []) const columns = React.useMemo(() => getColumns({ onDetail: handleDetail }), [handleDetail]) @@ -44,8 +35,6 @@ export function ComboBoxSettingsTable({ promises }: ComboBoxSettingsTableProps) id: "controlType", label: "Control Type", type: "select", options: [ { label: "Textbox", value: "textbox" }, { label: "Combobox", value: "combobox" }, - { label: "Date", value: "date" }, - { label: "Number", value: "number" }, ] }, { @@ -58,14 +47,12 @@ export function ComboBoxSettingsTable({ promises }: ComboBoxSettingsTableProps) ] const { table } = useDataTable({ - data: rawData[0].data as any, + data: rawData[0].data as typeof codeGroups.$inferSelect[], columns, pageCount: rawData[0].pageCount, enablePinning: true, enableAdvancedFilter: true, - manualSorting: false, initialState: { - sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, columnFilters: [ { @@ -86,7 +73,7 @@ export function ComboBoxSettingsTable({ promises }: ComboBoxSettingsTableProps) table={table} filterFields={advancedFilterFields} > - <ComboBoxSettingsTableToolbarActions table={table} onSuccess={refreshData} /> + </DataTableAdvancedToolbar> </DataTable> 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 deleted file mode 100644 index 28788bd7..00000000 --- a/lib/docu-list-rule/combo-box-settings/table/delete-combo-box-settings-dialog.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"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 deleted file mode 100644 index 8585d9a3..00000000 --- a/lib/docu-list-rule/combo-box-settings/table/document-class-options-sheet.tsx +++ /dev/null @@ -1,436 +0,0 @@ -"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 index a83651be..ca8e9192 100644 --- a/lib/docu-list-rule/combo-box-settings/validation.ts +++ b/lib/docu-list-rule/combo-box-settings/validation.ts @@ -1,12 +1,34 @@ -import { createSearchParamsCache } from "nuqs/server"; -import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server"; -import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" -export const searchParamsComboBoxOptionsCache = createSearchParamsCache({ +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { codeGroups } from "@/db/schema/docu-list-rule"; + +export const searchParamsComboBoxSettingsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<any>(), - filters: getFiltersStateParser(), - search: parseAsString.withDefault(""), + sort: getSortingStateParser<typeof codeGroups.$inferSelect>().withDefault([ + { id: "groupId", desc: false }, + ]), + + groupId: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + controlType: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -});
\ No newline at end of file + search: parseAsString.withDefault(""), +}) + +export type GetComboBoxSettingsSchema = Awaited<ReturnType<typeof searchParamsComboBoxSettingsCache.parse>>
\ 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 index 99d85ea5..a1bb14a7 100644 --- a/lib/docu-list-rule/document-class/service.ts +++ b/lib/docu-list-rule/document-class/service.ts @@ -3,7 +3,7 @@ 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" +import { eq, desc, asc, sql, and } from "drizzle-orm" // Document Class 목록 조회 (A Class, B Class 등) export async function getDocumentClassCodeGroups(input: { @@ -69,7 +69,7 @@ export async function getDocumentClassCodeGroups(input: { } // 정렬 (안전한 필드 체크 적용) - let orderBy = sql`${documentClasses.createdAt} DESC` + let orderBy = sql`${documentClasses.code} ASC` if (sort && sort.length > 0) { const sortField = sort[0] // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 @@ -274,8 +274,34 @@ export async function updateDocumentClassCodeGroup(input: { } // Document Class 삭제 +// Document Class의 옵션 개수 조회 +export async function getDocumentClassOptionsCount(documentClassId: number) { + try { + const result = await db + .select({ count: sql<number>`count(*)` }) + .from(documentClassOptions) + .where(eq(documentClassOptions.documentClassId, documentClassId)) + + return { + success: true, + count: result[0]?.count || 0 + } + } catch (error) { + console.error("Error getting document class options count:", error) + return { + success: false, + count: 0 + } + } +} + export async function deleteDocumentClassCodeGroup(id: number) { try { + // 먼저 해당 Document Class의 옵션들을 삭제 + await db + .delete(documentClassOptions) + .where(eq(documentClassOptions.documentClassId, id)) + // 삭제할 Document Class의 codeGroupId 확인 const documentClassToDelete = await db .select({ codeGroupId: documentClasses.codeGroupId }) @@ -310,7 +336,7 @@ export async function deleteDocumentClassCodeGroup(id: number) { return { success: true, data: deletedDocumentClass, - message: "Document Class deleted successfully" + message: "Document Class and its options deleted successfully" } } catch (error) { console.error("Error deleting document class:", error) @@ -322,22 +348,57 @@ export async function deleteDocumentClassCodeGroup(id: number) { } // Document Class 옵션 목록 조회 -export async function getDocumentClassSubOptions(documentClassId: number) { +export async function getDocumentClassSubOptions(documentClassId: 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 = 1000, sort, search } = input || {} + const offset = (page - 1) * perPage + + // 기본 조건: documentClassId + let whereConditions = eq(documentClassOptions.documentClassId, documentClassId) + + // 검색 조건 + if (search) { + const searchTerm = `%${search}%` + whereConditions = sql`${whereConditions} AND ( + ${documentClassOptions.optionCode} ILIKE ${searchTerm} OR + ${documentClassOptions.description} ILIKE ${searchTerm} + )` + } + + // 정렬 (안전한 필드 체크 적용) + let orderBy = sql`${documentClassOptions.optionCode} ASC` + if (sort && sort.length > 0) { + const sortField = sort[0] + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (sortField && sortField.id && typeof sortField.id === "string" && sortField.id in documentClassOptions) { + const direction = sortField.desc ? sql`DESC` : sql`ASC` + const col = documentClassOptions[sortField.id as keyof typeof documentClassOptions] + orderBy = sql`${col} ${direction}` + } + } + const data = await db .select({ id: documentClassOptions.id, documentClassId: documentClassOptions.documentClassId, - optionValue: documentClassOptions.optionValue, + description: documentClassOptions.description, 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)) + .where(whereConditions) + .orderBy(orderBy) + .limit(perPage) + .offset(offset) return { success: true, @@ -356,7 +417,7 @@ export async function getDocumentClassSubOptions(documentClassId: number) { // Document Class 옵션 생성 export async function createDocumentClassOptionItem(input: { documentClassId: number - optionValue: string + optionCode: string }) { try { // Document Class 정보 조회하여 Value 가져오기 @@ -373,30 +434,27 @@ export async function createDocumentClassOptionItem(input: { } } - // 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 }) + + // 사용자가 입력한 코드를 그대로 사용 + const userOptionCode = input.optionCode.toUpperCase().trim() + + // 같은 Document Class 내에서 코드 중복 체크 + const existingOption = await db + .select({ id: documentClassOptions.id }) .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` - } + .where( + and( + eq(documentClassOptions.documentClassId, input.documentClassId), + eq(documentClassOptions.optionCode, userOptionCode) + ) + ) + .limit(1) + + if (existingOption.length > 0) { + return { + success: false, + error: "이미 존재하는 코드입니다." } } @@ -404,9 +462,8 @@ export async function createDocumentClassOptionItem(input: { .insert(documentClassOptions) .values({ documentClassId: input.documentClassId, - optionValue: input.optionValue, - optionCode: newOptionCode, - sortOrder: 0, + description: userOptionCode, // 코드값을 description에도 자동 설정 + optionCode: userOptionCode, isActive: true, }) .returning({ id: documentClassOptions.id }) @@ -430,13 +487,49 @@ export async function createDocumentClassOptionItem(input: { // Document Class 옵션 수정 export async function updateDocumentClassOption(input: { id: number - optionValue: string + optionCode: string }) { try { + const userOptionCode = input.optionCode.toUpperCase().trim() + + // 기존 옵션 조회하여 documentClassId 가져오기 + const currentOption = await db + .select({ documentClassId: documentClassOptions.documentClassId }) + .from(documentClassOptions) + .where(eq(documentClassOptions.id, input.id)) + .limit(1) + + if (!currentOption.length) { + return { + success: false, + error: "옵션을 찾을 수 없습니다." + } + } + + // 같은 Document Class 내에서 코드 중복 체크 (자신 제외) + const existingOption = await db + .select({ id: documentClassOptions.id }) + .from(documentClassOptions) + .where( + and( + eq(documentClassOptions.documentClassId, currentOption[0].documentClassId), + eq(documentClassOptions.optionCode, userOptionCode) + ) + ) + .limit(1) + + if (existingOption.length > 0 && existingOption[0].id !== input.id) { + return { + success: false, + error: "이미 존재하는 코드입니다." + } + } + const [updatedOption] = await db .update(documentClassOptions) .set({ - optionValue: input.optionValue, + description: userOptionCode, // 코드값을 description에도 자동 설정 + optionCode: userOptionCode, updatedAt: new Date(), }) .where(eq(documentClassOptions.id, input.id)) @@ -480,4 +573,4 @@ export async function deleteDocumentClassOption(id: number) { error: "Failed to delete document class option" } } -}
\ No newline at end of file +}
\ 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 index 677fe8ef..e81e4df6 100644 --- 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 @@ -28,8 +28,8 @@ import { DrawerTrigger, } from "@/components/ui/drawer" -import { deleteDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service" -import { documentClasses } from "@/db/schema/documentClasses" +import { deleteDocumentClassCodeGroup, getDocumentClassOptionsCount } from "@/lib/docu-list-rule/document-class/service" +import { documentClasses } from "@/db/schema" interface DeleteDocumentClassDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,8 +45,32 @@ export function DeleteDocumentClassDialog({ ...props }: DeleteDocumentClassDialogProps) { const [isDeletePending, startDeleteTransition] = React.useTransition() + const [optionsCounts, setOptionsCounts] = React.useState<Record<number, number>>({}) + const [isLoadingOptions, setIsLoadingOptions] = React.useState(false) const isDesktop = useMediaQuery("(min-width: 640px)") + // Document Class들의 옵션 개수 조회 + React.useEffect(() => { + const fetchOptionsCounts = async () => { + setIsLoadingOptions(true) + const counts: Record<number, number> = {} + + for (const docClass of documentClasses) { + const result = await getDocumentClassOptionsCount(docClass.id) + if (result.success) { + counts[docClass.id] = result.count + } + } + + setOptionsCounts(counts) + setIsLoadingOptions(false) + } + + if (documentClasses.length > 0) { + fetchOptionsCounts() + } + }, [documentClasses]) + function onDelete() { startDeleteTransition(async () => { try { @@ -76,7 +100,7 @@ export function DeleteDocumentClassDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm"> <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({documentClasses.length}) + delete ({documentClasses.length}) </Button> </DialogTrigger> ) : null} @@ -87,6 +111,26 @@ export function DeleteDocumentClassDialog({ 이 작업은 되돌릴 수 없습니다. 선택된{" "} <span className="font-medium">{documentClasses.length}</span> 개의 Document Class를 서버에서 영구적으로 삭제합니다. + {isLoadingOptions ? ( + <div className="mt-2 text-sm text-muted-foreground"> + 옵션 정보를 확인하는 중... + </div> + ) : ( + documentClasses.some(docClass => optionsCounts[docClass.id] > 0) && ( + <div className="mt-2 text-sm text-orange-600"> + ⚠️ 다음 Document Class들은 옵션을 가지고 있어 함께 삭제됩니다: + <ul className="mt-1 ml-4 list-disc"> + {documentClasses + .filter(docClass => optionsCounts[docClass.id] > 0) + .map(docClass => ( + <li key={docClass.id}> + {docClass.code} ({optionsCounts[docClass.id]}개 옵션) + </li> + ))} + </ul> + </div> + ) + )} </DialogDescription> </DialogHeader> <DialogFooter className="gap-2 sm:space-x-0"> @@ -123,15 +167,35 @@ export function DeleteDocumentClassDialog({ </Button> </DrawerTrigger> ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 되돌릴 수 없습니다. 선택된{" "} - <span className="font-medium">{documentClasses.length}</span> - 개의 Document Class를 서버에서 영구적으로 삭제합니다. - </DrawerDescription> - </DrawerHeader> + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{documentClasses.length}</span> + 개의 Document Class를 서버에서 영구적으로 삭제합니다. + {isLoadingOptions ? ( + <div className="mt-2 text-sm text-muted-foreground"> + 옵션 정보를 확인하는 중... + </div> + ) : ( + documentClasses.some(docClass => optionsCounts[docClass.id] > 0) && ( + <div className="mt-2 text-sm text-orange-600"> + ⚠️ 다음 Document Class들은 옵션을 가지고 있어 함께 삭제됩니다: + <ul className="mt-1 ml-4 list-disc"> + {documentClasses + .filter(docClass => optionsCounts[docClass.id] > 0) + .map(docClass => ( + <li key={docClass.id}> + {docClass.code} ({optionsCounts[docClass.id]}개 옵션) + </li> + ))} + </ul> + </div> + ) + )} + </DrawerDescription> + </DrawerHeader> <DrawerFooter className="gap-2 sm:space-x-0"> <DrawerClose asChild> <Button variant="outline">취소</Button> 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 index f0fcbc34..34ce239f 100644 --- 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 @@ -28,7 +28,7 @@ import { } from "@/components/ui/drawer" import { deleteDocumentClassOption } from "@/lib/docu-list-rule/document-class/service" -import { documentClassOptions } from "@/db/schema/documentClasses" +import { documentClassOptions } from "@/db/schema" interface DeleteDocumentClassOptionDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { 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 index 97729caa..5ad23b22 100644 --- 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 @@ -28,7 +28,7 @@ import { import { Input } from "@/components/ui/input" import { updateDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-class/service" -import { documentClasses } from "@/db/schema/documentClasses" +import { documentClasses } from "@/db/schema" const updateDocumentClassSchema = z.object({ value: z.string().min(1, "Value는 필수입니다."), 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 index 5bfcbd33..93681c09 100644 --- 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 @@ -28,105 +28,93 @@ import { 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, "옵션 값은 필수입니다."), +const createOptionSchema = z.object({ + optionCode: z.string().min(1, "코드는 필수입니다."), }) -type CreateDocumentClassOptionSchema = z.infer<typeof createDocumentClassOptionSchema> +type CreateOptionSchema = z.infer<typeof createOptionSchema> interface DocumentClassOptionAddDialogProps { - selectedDocumentClass: typeof documentClasses.$inferSelect | null + documentClassId: number onSuccess?: () => void } -export function DocumentClassOptionAddDialog({ - selectedDocumentClass, - onSuccess, -}: DocumentClassOptionAddDialogProps) { +export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: DocumentClassOptionAddDialogProps) { const [open, setOpen] = React.useState(false) const [isPending, startTransition] = React.useTransition() - const form = useForm<CreateDocumentClassOptionSchema>({ - resolver: zodResolver(createDocumentClassOptionSchema), + const form = useForm<CreateOptionSchema>({ + resolver: zodResolver(createOptionSchema), defaultValues: { - optionValue: "", + optionCode: "", }, - mode: "onChange" }) - async function onSubmit(input: CreateDocumentClassOptionSchema) { - if (!selectedDocumentClass) { - toast.error("Document Class가 선택되지 않았습니다.") - return - } - + const handleSubmit = (data: CreateOptionSchema) => { startTransition(async () => { try { const result = await createDocumentClassOptionItem({ - documentClassId: selectedDocumentClass.id, - optionValue: input.optionValue, + documentClassId, + optionCode: data.optionCode, }) - + if (result.success) { - toast.success("Document Class 옵션이 생성되었습니다.") - form.reset() + toast.success("옵션이 성공적으로 추가되었습니다.") setOpen(false) + form.reset() onSuccess?.() } else { - toast.error(result.error || "생성에 실패했습니다.") + toast.error(`옵션 추가 실패: ${result.error}`) } } catch (error) { console.error("Create error:", error) - toast.error("Document Class 옵션 생성 중 오류가 발생했습니다.") + toast.error("옵션 추가 중 오류가 발생했습니다.") } }) } const handleCancel = () => { - form.reset() setOpen(false) + form.reset() } return ( - <Dialog open={open && !!selectedDocumentClass} onOpenChange={setOpen}> + <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> - <Button variant="outline" size="sm" disabled={!selectedDocumentClass}> + <Button variant="outline" size="sm"> <Plus className="mr-2 h-4 w-4" /> 옵션 추가 </Button> </DialogTrigger> - <DialogContent className="sm:max-w-[425px]"> + <DialogContent> <DialogHeader> - <DialogTitle>Document Class 옵션 추가</DialogTitle> + <DialogTitle>옵션 추가</DialogTitle> <DialogDescription> - {selectedDocumentClass?.description || "Document Class"}에 새로운 옵션을 추가합니다. - <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + 새로운 Document Class 옵션을 추가합니다. </DialogDescription> </DialogHeader> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} - name="optionValue" + name="optionCode" render={({ field }) => ( <FormItem> - <FormLabel>옵션 값 *</FormLabel> + <FormLabel>코드</FormLabel> <FormControl> - <Input {...field} placeholder="옵션 값을 입력하세요" /> + <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> 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 index 6f6e7a87..bc2318c6 100644 --- 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 @@ -5,12 +5,10 @@ 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, @@ -26,14 +24,15 @@ import { 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" +import { documentClassOptions } from "@/db/schema" -const updateDocumentClassOptionSchema = z.object({ - optionValue: z.string().min(1, "옵션 값은 필수입니다."), +const updateOptionSchema = z.object({ + optionCode: z.string().min(1, "코드는 필수입니다."), }) -type UpdateDocumentClassOptionSchema = z.infer<typeof updateDocumentClassOptionSchema> +type UpdateOptionSchema = z.infer<typeof updateOptionSchema> interface DocumentClassOptionEditSheetProps { open: boolean @@ -48,91 +47,82 @@ export function DocumentClassOptionEditSheet({ data, onSuccess, }: DocumentClassOptionEditSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [isPending, startTransition] = React.useTransition() - const form = useForm<UpdateDocumentClassOptionSchema>({ - resolver: zodResolver(updateDocumentClassOptionSchema), + const form = useForm<UpdateOptionSchema>({ + resolver: zodResolver(updateOptionSchema), defaultValues: { - optionValue: data?.optionValue || "", + optionCode: "", }, - mode: "onChange" }) React.useEffect(() => { if (data) { form.reset({ - optionValue: data.optionValue || "", + optionCode: data.optionCode || "", }) } }, [data, form]) - async function onSubmit(input: UpdateDocumentClassOptionSchema) { + const handleSubmit = (formData: UpdateOptionSchema) => { if (!data) return - startUpdateTransition(async () => { + startTransition(async () => { try { const result = await updateDocumentClassOption({ id: data.id, - optionValue: input.optionValue, + optionCode: formData.optionCode, }) - + if (result.success) { - toast.success("Document Class 옵션이 성공적으로 수정되었습니다.") - onSuccess?.() + toast.success("옵션이 성공적으로 수정되었습니다.") onOpenChange(false) + onSuccess?.() } else { - toast.error(result.error || "수정에 실패했습니다.") + toast.error(`옵션 수정 실패: ${result.error}`) } } catch (error) { console.error("Update error:", error) - toast.error("Document Class 옵션 수정 중 오류가 발생했습니다.") + toast.error("옵션 수정 중 오류가 발생했습니다.") } }) } + const handleCancel = () => { + onOpenChange(false) + form.reset() + } + 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> + <SheetContent> + <SheetHeader> + <SheetTitle>옵션 수정</SheetTitle> <SheetDescription> - Document Class 옵션 정보를 수정하고 변경사항을 저장하세요 + Document Class 옵션을 수정합니다. </SheetDescription> </SheetHeader> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} - name="optionValue" + name="optionCode" render={({ field }) => ( <FormItem> - <FormLabel>옵션 값</FormLabel> + <FormLabel>코드</FormLabel> <FormControl> - <Input placeholder="옵션 값을 입력하세요" {...field} /> + <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" - /> - )} - 저장 + <SheetFooter> + <Button type="button" variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button type="submit" disabled={isPending || !form.formState.isValid}> + 수정 </Button> </SheetFooter> </form> diff --git a/lib/docu-list-rule/document-class/table/document-class-options-detail-sheet.tsx b/lib/docu-list-rule/document-class/table/document-class-options-detail-sheet.tsx new file mode 100644 index 00000000..50e79d89 --- /dev/null +++ b/lib/docu-list-rule/document-class/table/document-class-options-detail-sheet.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel } from "@tanstack/react-table" +import { DataTableDetail } from "@/components/data-table/data-table-detail" +import { DataTableAdvancedToolbarDetail } from "@/components/data-table/data-table-advanced-toolbar-detail" +import type { DataTableAdvancedFilterField, DataTableRowAction } from "@/types/table" +import { + Sheet, + SheetContent, +} from "@/components/ui/sheet" +import { getDocumentClassSubOptions } from "@/lib/docu-list-rule/document-class/service" +import { getColumns } from "@/lib/docu-list-rule/document-class/table/document-class-options-table-columns" +import { DocumentClassOptionEditSheet } from "@/lib/docu-list-rule/document-class/table/document-class-option-edit-sheet" +import { DeleteDocumentClassOptionDialog } from "@/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog" +import { DocumentClassOptionsTableToolbarActions } from "@/lib/docu-list-rule/document-class/table/document-class-options-table-toolbar" +import { documentClasses, documentClassOptions } from "@/db/schema/docu-list-rule" + +type DocumentClassOption = typeof documentClassOptions.$inferSelect + +interface DocumentClassOptionsDetailSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + documentClass: typeof documentClasses.$inferSelect | null + onSuccess?: () => void + promises?: Promise<[{ data: DocumentClassOption[]; pageCount: number }]> +} + +export function DocumentClassOptionsDetailSheet({ + open, + onOpenChange, + documentClass, + promises, +}: DocumentClassOptionsDetailSheetProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentClassOption> | null>(null) + const [rawData, setRawData] = React.useState<{ data: DocumentClassOption[]; pageCount: number }>({ data: [], pageCount: 0 }) + + React.useEffect(() => { + if (promises) { + promises.then(([result]) => { + setRawData(result) + }) + } else if (open && documentClass) { + // fallback: 클라이언트에서 직접 fetch (CSR) + (async () => { + try { + const result = await getDocumentClassSubOptions(documentClass.id) + if (result.success && result.data) { + setRawData({ + data: result.data, + pageCount: 1 + }) + } + } catch (error) { + console.error("Error refreshing data:", error) + } + })() + } + }, [promises, open, documentClass]) + + const refreshData = React.useCallback(async () => { + if (!documentClass) return + + try { + const result = await getDocumentClassSubOptions(documentClass.id, { + page: 1, + perPage: 10, + }) + if (result.success && result.data) { + setRawData({ + data: result.data, + pageCount: 1 + }) + } + } catch (error) { + console.error("Error refreshing data:", error) + } + }, [documentClass]) + + const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction]) + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<DocumentClassOption>[] = [ + { id: "optionCode", label: "코드", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + ] + + const table = useReactTable({ + data: rawData.data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + sorting: [{ id: "optionCode", desc: false }], + pagination: { + pageSize: 10, + }, + }, + getRowId: (originalRow) => String(originalRow.id), + }) + + if (!documentClass) 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">{documentClass.value} 옵션 관리</h3> + <p className="text-sm text-muted-foreground"> + {documentClass.value}의 Document Class 옵션들을 관리합니다. + </p> + </div> + </div> + + <DocumentClassOptionsTableToolbarActions + table={table} + documentClassId={documentClass.id} + onSuccess={refreshData} + /> + + <DataTableDetail table={table}> + <DataTableAdvancedToolbarDetail + table={table} + filterFields={advancedFilterFields} + /> + </DataTableDetail> + + <DeleteDocumentClassOptionDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + options={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + refreshData() + }} + /> + + <DocumentClassOptionEditSheet + 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/document-class/table/document-class-options-table-columns.tsx b/lib/docu-list-rule/document-class/table/document-class-options-table-columns.tsx index c04a7b37..c3bf440d 100644 --- 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 @@ -18,7 +18,7 @@ import { } from "@/components/ui/dropdown-menu" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { documentClassOptions } from "@/db/schema/documentClasses" +import { documentClassOptions } from "@/db/schema/docu-list-rule" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentClassOptions.$inferSelect> | null>> @@ -114,19 +114,18 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof minSize: 80 }, { - accessorKey: "optionValue", + accessorKey: "description", enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="옵션 값" /> + <DataTableColumnHeaderSimple column={column} title="설명" /> ), meta: { - excelHeader: "옵션 값", + excelHeader: "설명", type: "text", }, - cell: ({ row }) => row.getValue("optionValue") ?? "", - minSize: 80 + cell: ({ row }) => row.getValue("description") ?? "", + minSize: 120 }, - { accessorKey: "createdAt", enableResizing: true, @@ -141,7 +140,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof const dateVal = row.getValue("createdAt") as Date return formatDateTime(dateVal, "KR") }, - minSize: 80 + minSize: 100 } ] 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 index 5044d90d..0cd44a4f 100644 --- 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 @@ -3,23 +3,23 @@ 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" +import { DocumentClassOptionAddDialog } from "@/lib/docu-list-rule/document-class/table/document-class-option-add-dialog" +import { DeleteDocumentClassOptionDialog } from "@/lib/docu-list-rule/document-class/table/delete-document-class-option-dialog" +import { documentClassOptions } from "@/db/schema/docu-list-rule" -interface DocumentClassOptionsTableToolbarActionsProps<TData> { - table: Table<TData> - selectedDocumentClass: typeof documentClasses.$inferSelect | null +interface DocumentClassOptionsTableToolbarActionsProps { + table: Table<typeof documentClassOptions.$inferSelect> + documentClassId: number onSuccess?: () => void } -export function DocumentClassOptionsTableToolbarActions<TData>({ +export function DocumentClassOptionsTableToolbarActions({ table, - selectedDocumentClass, + documentClassId, onSuccess, -}: DocumentClassOptionsTableToolbarActionsProps<TData>) { +}: DocumentClassOptionsTableToolbarActionsProps) { const selectedRows = table.getFilteredSelectedRowModel().rows - const selectedOptions = selectedRows.map((row) => row.original as typeof documentClassOptions.$inferSelect) + const selectedOptions = selectedRows.map((row) => row.original) return ( <div className="flex items-center gap-2"> @@ -35,7 +35,7 @@ export function DocumentClassOptionsTableToolbarActions<TData>({ ) : null} <DocumentClassOptionAddDialog - selectedDocumentClass={selectedDocumentClass} + documentClassId={documentClassId} onSuccess={onSuccess} /> </div> 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 deleted file mode 100644 index 644e3599..00000000 --- a/lib/docu-list-rule/document-class/table/document-class-options-table.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"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 index 6684d13a..8c391def 100644 --- 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 @@ -22,12 +22,13 @@ import { documentClasses } from "@/db/schema/docu-list-rule" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof documentClasses.$inferSelect> | null>> + onDetail?: (documentClass: typeof documentClasses.$inferSelect) => void } /** * tanstack table 컬럼 정의 */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof documentClasses.$inferSelect>[] { +export function getColumns({ setRowAction, onDetail }: GetColumnsProps): ColumnDef<typeof documentClasses.$inferSelect>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- @@ -77,6 +78,11 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-40"> <DropdownMenuItem + onSelect={() => onDetail?.(row.original)} + > + Detail + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })} > Edit 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 index 7bc28a06..9b43f43d 100644 --- 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 @@ -5,7 +5,7 @@ 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" +import { documentClasses } from "@/db/schema" interface DocumentClassTableToolbarActionsProps { table: Table<typeof documentClasses.$inferSelect> 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 index e3daac8a..c66a1395 100644 --- a/lib/docu-list-rule/document-class/table/document-class-table.tsx +++ b/lib/docu-list-rule/document-class/table/document-class-table.tsx @@ -6,12 +6,11 @@ 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 { getColumns } from "@/lib/docu-list-rule/document-class/table/document-class-table-columns" +import { DocumentClassEditSheet } from "@/lib/docu-list-rule/document-class/table/document-class-edit-sheet" +import { DocumentClassOptionsDetailSheet } from "@/lib/docu-list-rule/document-class/table/document-class-options-detail-sheet" +import { DocumentClassTableToolbarActions } from "@/lib/docu-list-rule/document-class/table/document-class-table-toolbar" +import { DeleteDocumentClassDialog } from "@/lib/docu-list-rule/document-class/table/delete-document-class-dialog" import { documentClasses } from "@/db/schema/docu-list-rule" interface DocumentClassTableProps { @@ -22,6 +21,7 @@ export function DocumentClassTable({ promises }: DocumentClassTableProps) { const router = useRouter() const rawData = React.use(promises!) const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof documentClasses.$inferSelect> | null>(null) + const [isDetailSheetOpen, setIsDetailSheetOpen] = React.useState(false) const [selectedDocumentClass, setSelectedDocumentClass] = React.useState<typeof documentClasses.$inferSelect | null>(null) const refreshData = React.useCallback(() => { @@ -29,7 +29,13 @@ export function DocumentClassTable({ promises }: DocumentClassTableProps) { router.refresh() }, [router]) - const columns = React.useMemo(() => getColumns({ setRowAction }), [setRowAction]) + // Detail 버튼 클릭 핸들러 + const handleDetail = React.useCallback((documentClass: typeof documentClasses.$inferSelect) => { + setSelectedDocumentClass(documentClass) + setIsDetailSheetOpen(true) + }, []) + + const columns = React.useMemo(() => getColumns({ setRowAction, onDetail: handleDetail }), [setRowAction, handleDetail]) // 고급 필터 필드 설정 const advancedFilterFields: DataTableAdvancedFilterField<typeof documentClasses.$inferSelect>[] = [ @@ -40,14 +46,12 @@ export function DocumentClassTable({ promises }: DocumentClassTableProps) { ] const { table } = useDataTable({ - data: rawData[0].data as any, + data: rawData[0].data as typeof documentClasses.$inferSelect[], 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), @@ -66,26 +70,15 @@ export function DocumentClassTable({ promises }: DocumentClassTableProps) { </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} + {/* Detail 시트 */} + <DocumentClassOptionsDetailSheet + open={isDetailSheetOpen} + onOpenChange={setIsDetailSheetOpen} + documentClass={selectedDocumentClass} + onSuccess={() => { + setIsDetailSheetOpen(false) + setSelectedDocumentClass(null) + }} /> <DeleteDocumentClassDialog diff --git a/lib/docu-list-rule/document-class/validation.ts b/lib/docu-list-rule/document-class/validation.ts index 0600e8fb..78f87484 100644 --- a/lib/docu-list-rule/document-class/validation.ts +++ b/lib/docu-list-rule/document-class/validation.ts @@ -1,5 +1,5 @@ import { createSearchParamsCache } from "nuqs/server"; -import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server"; +import { parseAsInteger, parseAsString, parseAsStringEnum } from "nuqs/server"; import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers"; export const searchParamsDocumentClassCache = createSearchParamsCache({ diff --git a/lib/docu-list-rule/number-type-configs/repository.ts b/lib/docu-list-rule/number-type-configs/repository.ts deleted file mode 100644 index 56be1bcf..00000000 --- a/lib/docu-list-rule/number-type-configs/repository.ts +++ /dev/null @@ -1,269 +0,0 @@ -import db from "@/db/db" -import { documentNumberTypeConfigs } from "@/db/schema/docu-list-rule" -import { codeGroups, documentClasses } from "@/db/schema/docu-list-rule" -import { eq, asc, sql, count, ilike, or, and } from "drizzle-orm" -import { PgTransaction } from "drizzle-orm/pg-core" - -// Number Type Configs 조회 (고급 필터링 지원) -export async function selectNumberTypeConfigs( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db, - params: { - numberTypeId: number - // eslint-disable-next-line @typescript-eslint/no-explicit-any - where?: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - orderBy?: any[] - offset?: number - limit?: number - search?: string - } -) { - const { numberTypeId, where, orderBy, offset = 0, limit = 10, search } = params - - let query = tx - .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)) - - // 기본 조건: 특정 Number Type - let whereCondition = eq(documentNumberTypeConfigs.documentNumberTypeId, numberTypeId) - - // 고급 필터 조건 추가 - if (where) { - whereCondition = and(whereCondition, where) || whereCondition - } - - // 검색 조건 추가 - if (search) { - const searchTerm = `%${search}%` - const searchCondition = or( - ilike(codeGroups.description, searchTerm), - ilike(documentNumberTypeConfigs.description, searchTerm), - ilike(documentNumberTypeConfigs.remark, searchTerm), - ilike(documentClasses.value, searchTerm) - ) - if (searchCondition) { - whereCondition = and(whereCondition, searchCondition) || whereCondition - } - } - - query = query.where(whereCondition) - - // 정렬 적용 - if (orderBy && orderBy.length > 0) { - query = query.orderBy(...orderBy) - } else { - query = query.orderBy(asc(documentNumberTypeConfigs.sdq)) - } - - return query.offset(offset).limit(limit) -} - -// Number Type Configs 개수 조회 -export async function countNumberTypeConfigs( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db, - params: { - numberTypeId: number - // eslint-disable-next-line @typescript-eslint/no-explicit-any - where?: any - search?: string - } -) { - const { numberTypeId, where, search } = params - - let query = tx - .select({ count: count() }) - .from(documentNumberTypeConfigs) - .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) - .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) - - // 기본 조건: 특정 Number Type - let whereCondition = eq(documentNumberTypeConfigs.documentNumberTypeId, numberTypeId) - - // 고급 필터 조건 추가 - if (where) { - whereCondition = and(whereCondition, where) || whereCondition - } - - // 검색 조건 추가 - if (search) { - const searchTerm = `%${search}%` - const searchCondition = or( - ilike(codeGroups.description, searchTerm), - ilike(documentNumberTypeConfigs.description, searchTerm), - ilike(documentNumberTypeConfigs.remark, searchTerm), - ilike(documentClasses.value, searchTerm) - ) - if (searchCondition) { - whereCondition = and(whereCondition, searchCondition) || whereCondition - } - } - - const result = await query.where(whereCondition) - return result[0]?.count ?? 0 -} - -// Number Type Config 생성 -export async function insertNumberTypeConfig( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db, - input: { - documentNumberTypeId: number - codeGroupId: number | null - documentClassId: number | null - sdq: number - description?: string - remark?: string - } -) { - return tx - .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 }) -} - -// Number Type Config 수정 -export async function updateNumberTypeConfigById( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db, - id: number, - input: { - codeGroupId: number | null - documentClassId: number | null - sdq: number - description?: string - remark?: string - } -) { - return tx - .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, id)) - .returning({ id: documentNumberTypeConfigs.id }) -} - -// Number Type Config 삭제 -export async function deleteNumberTypeConfigById( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db, - id: number -) { - return tx - .delete(documentNumberTypeConfigs) - .where(eq(documentNumberTypeConfigs.id, id)) -} - -// 삭제할 Config 정보 조회 -export async function selectConfigToDelete( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db, - id: number -) { - return tx - .select({ - documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId, - sdq: documentNumberTypeConfigs.sdq, - }) - .from(documentNumberTypeConfigs) - .where(eq(documentNumberTypeConfigs.id, id)) -} - -// 순서 재정렬 (삭제 후) -export async function reorderConfigsAfterDelete( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db, - documentNumberTypeId: number, - deletedSdq: number -) { - return tx - .update(documentNumberTypeConfigs) - .set({ - sdq: sql`${documentNumberTypeConfigs.sdq} - 1`, - updatedAt: new Date(), - }) - .where( - sql`${documentNumberTypeConfigs.documentNumberTypeId} = ${documentNumberTypeId} AND ${documentNumberTypeConfigs.sdq} > ${deletedSdq}` - ) -} - -// 활성화된 Code Groups 조회 -export async function selectActiveCodeGroups( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db -) { - return tx - .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)) -} - -// Document Class Code Group ID 조회 -export async function selectDocumentClassCodeGroupId( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db -) { - return tx - .select({ id: codeGroups.id }) - .from(codeGroups) - .where(eq(codeGroups.groupId, 'DOC_CLASS')) - .limit(1) -} - -// 활성화된 Document Classes 조회 -export async function selectActiveDocumentClasses( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tx: PgTransaction<any, any, any> | typeof db -) { - return tx - .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)) -}
\ 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 index 1ba9b8f0..0b0bb905 100644 --- a/lib/docu-list-rule/number-type-configs/service.ts +++ b/lib/docu-list-rule/number-type-configs/service.ts @@ -3,111 +3,176 @@ import { revalidatePath } from "next/cache" import db from "@/db/db" import { unstable_noStore } from "next/cache" -import { unstable_cache } from "@/lib/unstable-cache" -import { filterColumns } from "@/lib/filter-columns" -import { documentNumberTypeConfigs, codeGroups, documentClasses } from "@/db/schema/docu-list-rule" -import { asc, desc, eq, sql } from "drizzle-orm" -import { - selectNumberTypeConfigs, - countNumberTypeConfigs, - insertNumberTypeConfig, - updateNumberTypeConfigById, - deleteNumberTypeConfigById, - selectConfigToDelete, - reorderConfigsAfterDelete, - selectActiveCodeGroups, - selectDocumentClassCodeGroupId, - selectActiveDocumentClasses, -} from "./repository" +import { documentNumberTypeConfigs, codeGroups } from "@/db/schema/docu-list-rule" +import { asc, eq, sql, count, and } from "drizzle-orm" import { GetNumberTypeConfigsSchema } from "./validation" -// 특정 Number Type의 Configs 조회 (고급 필터링 지원) +// 특정 Number Type의 Configs 조회 export async function getNumberTypeConfigs(input: GetNumberTypeConfigsSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage + unstable_noStore() + + try { + const { numberTypeId, page = 1, perPage = 10, search, filters, joinOperator } = input + const offset = (page - 1) * perPage - // 고급 필터링 지원 - const advancedWhere = filterColumns({ - table: documentNumberTypeConfigs, - filters: input.filters, - joinOperator: input.joinOperator, - }) + // numberTypeId 유효성 검사 + if (!numberTypeId || numberTypeId <= 0) { + return { + success: true, + data: [], + pageCount: 0, + } + } - // 정렬 처리 - const orderBy = input.sort.length > 0 - ? input.sort - .map((item) => { - // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 - if (!item || !item.id || typeof item.id !== "string" || !(item.id in documentNumberTypeConfigs)) { - return null; - } - const col = documentNumberTypeConfigs[item.id as keyof typeof documentNumberTypeConfigs]; - return item.desc ? desc(col) : asc(col); - }) - .filter((v): v is Exclude<typeof v, null> => v !== null) - : [asc(documentNumberTypeConfigs.sdq)] + // 기본 조건: 특정 Number Type + let whereConditions = eq(documentNumberTypeConfigs.documentNumberTypeId, numberTypeId) - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectNumberTypeConfigs(tx, { - numberTypeId: input.numberTypeId, - where: advancedWhere, - orderBy, - offset, - limit: input.perPage, - search: input.search || undefined, - }) + // 검색 조건 추가 + if (search && search.trim() !== "") { + const searchTerm = `%${search}%` + whereConditions = sql`${whereConditions} AND ( + ${codeGroups.description} ILIKE ${searchTerm} OR + ${documentNumberTypeConfigs.description} ILIKE ${searchTerm} OR + ${documentNumberTypeConfigs.remark} ILIKE ${searchTerm} + )` + } - const total = await countNumberTypeConfigs(tx, { - numberTypeId: input.numberTypeId, - where: advancedWhere, - search: input.search || undefined, - }) + // 고급 필터링 (Code Groups 스타일) + if (filters && filters.length > 0) { + const filterConditions = filters.map(filter => { + const { id, value } = filter + if (!value || Array.isArray(value)) return null + + switch (id) { + case "codeGroupName": + return sql`${codeGroups.description} ILIKE ${`%${value}%`}` + case "description": + return sql`${documentNumberTypeConfigs.description} ILIKE ${`%${value}%`}` + case "remark": + return sql`${documentNumberTypeConfigs.remark} ILIKE ${`%${value}%`}` + case "sdq": + return sql`${documentNumberTypeConfigs.sdq} = ${parseInt(value)}` + case "isActive": + return sql`${documentNumberTypeConfigs.isActive} = ${value === "true"}` + default: + return null + } + }).filter(Boolean) - return { data, total } + if (filterConditions.length > 0) { + const operator = joinOperator === "or" ? sql` OR ` : sql` AND ` + const combinedFilters = filterConditions.reduce((acc, condition, index) => { + if (index === 0) return condition + return sql`${acc}${operator}${condition}` }) + + whereConditions = and(whereConditions, sql`(${combinedFilters})`) || whereConditions + } + } - const pageCount = Math.ceil(total / input.perPage) - - return { - success: true, - data, - pageCount, - } - } catch (error) { - console.error("Error fetching number type configs:", error) - return { - success: false, - error: "Failed to fetch number type configs", - data: [], - pageCount: 0, + // 정렬 (Code Groups 스타일) + let orderBy = sql`${documentNumberTypeConfigs.sdq} ASC` + if (input.sort && input.sort.length > 0) { + const sortField = input.sort[0] + // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 + if (sortField && sortField.id && typeof sortField.id === "string") { + const direction = sortField.desc ? sql`DESC` : sql`ASC` + + // 정렬 필드에 따른 컬럼 선택 + let col: typeof documentNumberTypeConfigs.sdq | typeof documentNumberTypeConfigs.description | typeof documentNumberTypeConfigs.remark | typeof codeGroups.description | typeof documentNumberTypeConfigs.createdAt | typeof documentNumberTypeConfigs.updatedAt + switch (sortField.id) { + case "sdq": + col = documentNumberTypeConfigs.sdq + break + case "description": + col = documentNumberTypeConfigs.description + break + case "remark": + col = documentNumberTypeConfigs.remark + break + case "codeGroupName": + col = codeGroups.description + break + case "createdAt": + col = documentNumberTypeConfigs.createdAt + break + case "updatedAt": + col = documentNumberTypeConfigs.updatedAt + break + default: + col = documentNumberTypeConfigs.sdq } + + orderBy = sql`${col} ${direction}` } - }, - [JSON.stringify(input)], // 캐싱 키 - { - revalidate: 300, // 5분 캐시 - tags: ["number-type-configs"], } - )() + + // 데이터 조회 + const data = await db + .select({ + id: documentNumberTypeConfigs.id, + documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId, + codeGroupId: documentNumberTypeConfigs.codeGroupId, + sdq: documentNumberTypeConfigs.sdq, + description: documentNumberTypeConfigs.description, + remark: documentNumberTypeConfigs.remark, + isActive: documentNumberTypeConfigs.isActive, + createdAt: documentNumberTypeConfigs.createdAt, + updatedAt: documentNumberTypeConfigs.updatedAt, + codeGroupName: codeGroups.description, + codeGroupControlType: codeGroups.controlType, + }) + .from(documentNumberTypeConfigs) + .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) + .where(whereConditions) + .orderBy(orderBy) + .limit(perPage) + .offset(offset) + + // 총 개수 조회 + const [{ count: total }] = await db + .select({ count: count() }) + .from(documentNumberTypeConfigs) + .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) + .where(whereConditions) + + const pageCount = Math.ceil(total / perPage) + + return { + success: true, + data, + pageCount, + } + } catch (error) { + console.error("Error fetching number type configs:", error) + return { + success: false, + error: "Failed to fetch number type configs", + data: [], + pageCount: 0, + } + } } // Number Type Config 생성 export async function createNumberTypeConfig(input: { documentNumberTypeId: number codeGroupId: number | null - documentClassId: number | null sdq: number description?: string remark?: string }) { try { - const result = await db.transaction(async (tx) => { - const [newConfig] = await insertNumberTypeConfig(tx, input) - return newConfig - }) + const [result] = await db + .insert(documentNumberTypeConfigs) + .values({ + documentNumberTypeId: input.documentNumberTypeId, + codeGroupId: input.codeGroupId, + sdq: input.sdq, + description: input.description, + remark: input.remark, + }) + .returning({ id: documentNumberTypeConfigs.id }) revalidatePath("/evcp/docu-list-rule/number-type-configs") @@ -129,22 +194,22 @@ export async function createNumberTypeConfig(input: { export async function updateNumberTypeConfig(input: { id: number codeGroupId: number | null - documentClassId: number | null sdq: number description?: string remark?: string }) { try { - const result = await db.transaction(async (tx) => { - const [updatedConfig] = await updateNumberTypeConfigById(tx, input.id, { + const [result] = await db + .update(documentNumberTypeConfigs) + .set({ codeGroupId: input.codeGroupId, - documentClassId: input.documentClassId, sdq: input.sdq, description: input.description, remark: input.remark, + updatedAt: new Date(), }) - return updatedConfig - }) + .where(eq(documentNumberTypeConfigs.id, input.id)) + .returning({ id: documentNumberTypeConfigs.id }) revalidatePath("/evcp/docu-list-rule/number-type-configs") @@ -165,29 +230,40 @@ export async function updateNumberTypeConfig(input: { // Number Type Config 삭제 export async function deleteNumberTypeConfig(id: number) { try { - await db.transaction(async (tx) => { - // 삭제할 항목의 정보를 먼저 가져옴 - const [configToDelete] = await selectConfigToDelete(tx, id) + // 삭제할 Config 정보 조회 + const [configToDelete] = await db + .select({ + documentNumberTypeId: documentNumberTypeConfigs.documentNumberTypeId, + sdq: documentNumberTypeConfigs.sdq, + }) + .from(documentNumberTypeConfigs) + .where(eq(documentNumberTypeConfigs.id, id)) - if (!configToDelete) { - throw new Error("Config not found") - } + if (!configToDelete) { + throw new Error("Config not found") + } - // 항목 삭제 - await deleteNumberTypeConfigById(tx, id) + // Config 삭제 + await db + .delete(documentNumberTypeConfigs) + .where(eq(documentNumberTypeConfigs.id, id)) - // 같은 Number Type의 남은 항목들 중에서 삭제된 항목보다 큰 순서를 가진 항목들의 순서를 1씩 감소 - await reorderConfigsAfterDelete( - tx, - configToDelete.documentNumberTypeId, - configToDelete.sdq + // 순서 재정렬 (삭제된 순서보다 큰 모든 항목의 순서를 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, + data: configToDelete, message: "Number Type Config deleted successfully" } } catch (error) { @@ -212,7 +288,17 @@ export async function getActiveCodeGroups() { unstable_noStore() try { - const codeGroupsData = await selectActiveCodeGroups(db) + 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)) return { success: true, @@ -228,43 +314,4 @@ export async function getActiveCodeGroups() { } } -// Document Class Code Group ID 조회 -export async function getDocumentClassCodeGroupId() { - try { - const [codeGroup] = await selectDocumentClassCodeGroupId(db) - 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() { - unstable_noStore() - - try { - const documentClassesData = await db - .select({ - id: documentClasses.id, - code: documentClasses.code, - value: documentClasses.value, - description: documentClasses.description, - }) - .from(documentClasses) - .where(eq(documentClasses.isActive, true)) - .orderBy(asc(documentClasses.value)) - - 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 +
\ 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 index 9ed8ca71..96c7e7c7 100644 --- 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 @@ -78,7 +78,7 @@ export function DeleteNumberTypeConfigsDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm"> <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({configs.length}) + delete ({configs.length}) </Button> </DialogTrigger> ) : null} 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 index 72ee2698..69d84a3f 100644 --- 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 @@ -100,7 +100,7 @@ export function NumberTypeConfigsEditDialog({ const result = await updateNumberTypeConfig({ id: data.id, codeGroupId: parseInt(formData.codeGroupId), - documentClassId: data.documentClassId, // 누락된 필드 추가 + sdq: newSdq, description: formData.description || undefined, remark: formData.remark || undefined, 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 index 3b3d0180..3e4dd262 100644 --- 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 @@ -62,6 +62,7 @@ export function NumberTypeConfigsTable({ promises, searchParams }: NumberTypeCon // 첫 번째 타입의 configs 데이터도 바로 로드 await fetchConfigs(firstNumberTypeId) } + } catch (error) { console.error("Error in loadData:", error) } @@ -69,6 +70,7 @@ export function NumberTypeConfigsTable({ promises, searchParams }: NumberTypeCon loadData() }, [rawData, fetchConfigs]) + // Number Type 변경 핸들러 (서버 사이드 처리 지원) const handleNumberTypeChange = React.useCallback((numberTypeId: number) => { @@ -102,7 +104,7 @@ export function NumberTypeConfigsTable({ promises, searchParams }: NumberTypeCon await updateNumberTypeConfig({ id: config.id, codeGroupId: config.codeGroupId, - documentClassId: config.documentClassId, + sdq: config.sdq, description: config.description || undefined, remark: config.remark || undefined, @@ -142,15 +144,13 @@ export function NumberTypeConfigsTable({ promises, searchParams }: NumberTypeCon pageCount: configsData.pageCount, enablePinning: true, enableAdvancedFilter: true, - manualSorting: true, - manualFiltering: true, - manualPagination: true, + enableGlobalFilter: true, // 전역 검색 활성화 initialState: { sorting: [{ id: "sdq", desc: false }], }, getRowId: (row) => String(row.id), shallow: false, - clearOnDefault: true, + clearOnDefault: true, // 검색어 지울 때 URL 파라미터도 지우도록 true로 변경 }) const refreshData = React.useCallback(async () => { @@ -159,18 +159,44 @@ export function NumberTypeConfigsTable({ promises, searchParams }: NumberTypeCon } }, [selectedNumberType, fetchConfigs]) - // 테이블 상태가 변경될 때 서버 데이터 다시 로드 + // 테이블 상태가 변경될 때 서버 데이터 다시 로드 (디바운싱 적용) + const [lastSearchTerm, setLastSearchTerm] = React.useState<string>("") + React.useEffect(() => { if (selectedNumberType) { const state = table.getState() - fetchConfigs(selectedNumberType, { - page: state.pagination.pageIndex + 1, - perPage: state.pagination.pageSize, - sort: state.sorting.map(s => ({ id: s.id as keyof NumberTypeConfig, desc: s.desc })), - search: state.globalFilter || "", - }) + const currentSearchTerm = state.globalFilter || "" + + console.log("🔍 useEffect triggered - currentSearchTerm:", `"${currentSearchTerm}"`, "lastSearchTerm:", `"${lastSearchTerm}"`) + + // 검색어가 실제로 변경되었을 때만 API 호출 + if (currentSearchTerm !== lastSearchTerm) { + console.log("🔍 Search term changed from:", `"${lastSearchTerm}"`, "to:", `"${currentSearchTerm}"`) + setLastSearchTerm(currentSearchTerm) + + fetchConfigs(selectedNumberType, { + page: state.pagination.pageIndex + 1, + perPage: state.pagination.pageSize, + sort: state.sorting.map(s => ({ id: s.id as keyof NumberTypeConfig, desc: s.desc })), + search: currentSearchTerm, + }) + } else { + console.log("🔍 Search term unchanged, skipping API call") + } + } + }, [table, selectedNumberType, fetchConfigs, lastSearchTerm]) + + // URL 파라미터에서 검색어를 가져와서 테이블 상태 동기화 + React.useEffect(() => { + console.log("🔍 URL searchParams.search:", searchParams.search) + if (searchParams.search && searchParams.search.trim() !== "") { + console.log("🔍 Syncing search from URL:", searchParams.search) + table.setGlobalFilter(searchParams.search) + } else if (searchParams.search === undefined || searchParams.search === "") { + console.log("🔍 Clearing search filter from URL") + table.setGlobalFilter("") } - }, [table, selectedNumberType, fetchConfigs]) + }, [searchParams.search, table]) return ( <> diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx index 4923bd1d..b30ee268 100644 --- a/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx @@ -108,7 +108,7 @@ export function NumberTypeConfigsToolbarActions({ const result = await createNumberTypeConfig({ documentNumberTypeId: selectedNumberType, codeGroupId: codeGroupId, - documentClassId: null, + sdq: sdq, description: formData.description || undefined, remark: formData.remark || undefined, diff --git a/lib/docu-list-rule/number-type-configs/validation.ts b/lib/docu-list-rule/number-type-configs/validation.ts index 02b99290..deb8193a 100644 --- a/lib/docu-list-rule/number-type-configs/validation.ts +++ b/lib/docu-list-rule/number-type-configs/validation.ts @@ -1,11 +1,19 @@ -import { createSearchParamsCache } from "nuqs/server"; -import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server"; -import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" import * as z from "zod" -import { NumberTypeConfig } from "../types"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { NumberTypeConfig } from "../types" export const searchParamsNumberTypeConfigsCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), sort: getSortingStateParser<NumberTypeConfig>().withDefault([ @@ -13,7 +21,7 @@ export const searchParamsNumberTypeConfigsCache = createSearchParamsCache({ ]), // Number Type ID (필수) - numberTypeId: parseAsInteger.withDefault(0), + numberTypeId: parseAsInteger.withDefault(1), // 기본 필터들 codeGroupName: parseAsString.withDefault(""), @@ -25,6 +33,6 @@ export const searchParamsNumberTypeConfigsCache = createSearchParamsCache({ filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), search: parseAsString.withDefault(""), -}); +}) export type GetNumberTypeConfigsSchema = Awaited<ReturnType<typeof searchParamsNumberTypeConfigsCache.parse>>
\ 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 index 17373848..12c3bf73 100644 --- a/lib/docu-list-rule/number-types/service.ts +++ b/lib/docu-list-rule/number-types/service.ts @@ -69,7 +69,7 @@ export async function getNumberTypes(input: { } // 정렬 (안전한 필드 체크 적용) - let orderBy = sql`${documentNumberTypes.createdAt} DESC` + let orderBy = sql`${documentNumberTypes.name} ASC` if (sort && sort.length > 0) { const sortField = sort[0] // 안전성 체크: 필드가 실제 테이블에 존재하는지 확인 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 index 8d1bc679..f0d80350 100644 --- 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 @@ -27,7 +27,7 @@ import { DrawerTrigger, } from "@/components/ui/drawer" -import { deleteNumberType } from "../service" +import { deleteNumberType } from "@/lib/docu-list-rule/number-types/service" import { documentNumberTypes } from "@/db/schema/docu-list-rule" interface DeleteNumberTypesDialogProps @@ -74,7 +74,7 @@ export function DeleteNumberTypesDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm"> <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 ({numberTypes.length}) + delete ({numberTypes.length}) </Button> </DialogTrigger> ) : null} 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 index 78c3ec37..964a6bd0 100644 --- 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 @@ -16,7 +16,7 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { toast } from "sonner" -import { createNumberType } from "../service" +import { createNumberType } from "@/lib/docu-list-rule/number-types/service" interface NumberTypeAddDialogProps { onSuccess: () => void 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 index f4066cea..d7daf149 100644 --- 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 @@ -26,7 +26,7 @@ import { SheetTitle, } from "@/components/ui/sheet" import { Input } from "@/components/ui/input" -import { updateNumberType } from "../service" +import { updateNumberType } from "@/lib/docu-list-rule/number-types/service" import { documentNumberTypes } from "@/db/schema/docu-list-rule" const updateNumberTypeSchema = z.object({ 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 index 306a3d74..28afe63b 100644 --- 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 @@ -6,8 +6,8 @@ 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 { DeleteNumberTypesDialog } from "@/lib/docu-list-rule/number-types/table/delete-number-types-dialog" +import { NumberTypeAddDialog } from "@/lib/docu-list-rule/number-types/table/number-type-add-dialog" import { documentNumberTypes } from "@/db/schema/docu-list-rule" interface NumberTypesTableToolbarActionsProps { @@ -33,21 +33,6 @@ export function NumberTypesTableToolbarActions({ table, onSuccess }: NumberTypes <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 index 00f7e2ab..18aa1a30 100644 --- a/lib/docu-list-rule/number-types/table/number-types-table.tsx +++ b/lib/docu-list-rule/number-types/table/number-types-table.tsx @@ -6,12 +6,11 @@ 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 { getColumns } from "@/lib/docu-list-rule/number-types/table/number-types-table-columns" +import { DeleteNumberTypesDialog } from "@/lib/docu-list-rule/number-types/table/delete-number-types-dialog" +import { NumberTypesTableToolbarActions } from "@/lib/docu-list-rule/number-types/table/number-types-table-toolbar" import { documentNumberTypes } from "@/db/schema/docu-list-rule" +import { NumberTypeEditSheet } from "@/lib/docu-list-rule/number-types/table/number-type-edit-sheet" interface NumberTypesTableProps { promises?: Promise<[{ data: typeof documentNumberTypes.$inferSelect[]; pageCount: number }]> @@ -47,7 +46,6 @@ export function NumberTypesTable({ promises }: NumberTypesTableProps) { pageCount: rawData[0].pageCount, enablePinning: true, enableAdvancedFilter: true, - manualSorting: false, initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, diff --git a/lib/docu-list-rule/number-types/validation.ts b/lib/docu-list-rule/number-types/validation.ts index 72e2921d..6382ee5a 100644 --- a/lib/docu-list-rule/number-types/validation.ts +++ b/lib/docu-list-rule/number-types/validation.ts @@ -1,5 +1,5 @@ import { createSearchParamsCache } from "nuqs/server"; -import { parseAsInteger, parseAsString, parseAsArrayOf, parseAsStringEnum } from "nuqs/server"; +import { parseAsInteger, parseAsString, parseAsStringEnum } from "nuqs/server"; import { getSortingStateParser, getFiltersStateParser } from "@/lib/parsers"; export const searchParamsNumberTypesCache = createSearchParamsCache({ diff --git a/lib/docu-list-rule/types.ts b/lib/docu-list-rule/types.ts index a5277132..cb80316a 100644 --- a/lib/docu-list-rule/types.ts +++ b/lib/docu-list-rule/types.ts @@ -4,7 +4,6 @@ export interface NumberTypeConfig { id: number documentNumberTypeId: number codeGroupId: number | null - documentClassId: number | null sdq: number description: string | null remark: string | null @@ -13,8 +12,6 @@ export interface NumberTypeConfig { updatedAt: Date codeGroupName: string | null codeGroupControlType: string | null - documentClassName: string | null - documentClassDescription: string | null } export interface CodeGroup { |
