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/docu-list-rule/document-class | |
| parent | f0213de0d2fb5fcb931b3ddaddcbb6581cab5d28 (diff) | |
(박서영) docu-list-rule detail sheet 컴포넌트 추가 및 검색 필터 기능 오류 수정
Diffstat (limited to 'lib/docu-list-rule/document-class')
14 files changed, 474 insertions, 365 deletions
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({ |
