diff options
Diffstat (limited to 'lib/payment-terms')
| -rw-r--r-- | lib/payment-terms/service.ts | 151 | ||||
| -rw-r--r-- | lib/payment-terms/table/payment-terms-add-dialog.tsx | 162 | ||||
| -rw-r--r-- | lib/payment-terms/table/payment-terms-edit-sheet.tsx | 147 | ||||
| -rw-r--r-- | lib/payment-terms/table/payment-terms-table-columns.tsx | 102 | ||||
| -rw-r--r-- | lib/payment-terms/table/payment-terms-table-toolbar.tsx | 16 | ||||
| -rw-r--r-- | lib/payment-terms/table/payment-terms-table.tsx | 127 | ||||
| -rw-r--r-- | lib/payment-terms/validations.ts | 34 |
7 files changed, 739 insertions, 0 deletions
diff --git a/lib/payment-terms/service.ts b/lib/payment-terms/service.ts new file mode 100644 index 00000000..4ca3efd9 --- /dev/null +++ b/lib/payment-terms/service.ts @@ -0,0 +1,151 @@ +"use server"; +import db from "@/db/db"; +import {paymentTerms} from "@/db/schema/procurementRFQ" +import { GetPaymentTermsSchema } from "./validations"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, and, or, count, eq } from "drizzle-orm"; + + +// PaymentTerms CRUD +export async function getPaymentTerms(input: GetPaymentTermsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. where 절 + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: paymentTerms, + filters: input.filters, + joinOperator: input.joinOperator, + }); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(paymentTerms.code, s), + ilike(paymentTerms.description, s) + ); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 2. where 결합 + let finalWhere; + if (advancedWhere && globalWhere) { + finalWhere = and(advancedWhere, globalWhere); + } else { + finalWhere = advancedWhere || globalWhere; + } + + // 3. order by + let orderBy; + try { + orderBy = + input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== "string" || !(item.id in paymentTerms)) return null; + const col = paymentTerms[item.id as keyof typeof paymentTerms]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [asc(paymentTerms.createdAt)]; + } catch (orderErr) { + console.error("Error building order by:", orderErr); + orderBy = [asc(paymentTerms.createdAt)]; + } + + // 4. 쿼리 실행 + let data = []; + let total = 0; + + try { + const queryBuilder = db.select().from(paymentTerms); + + if (finalWhere) { + queryBuilder.where(finalWhere); + } + + if (orderBy && orderBy.length > 0) { + queryBuilder.orderBy(...orderBy); + } + if (typeof offset === "number" && !isNaN(offset)) { + queryBuilder.offset(offset); + } + if (typeof input.perPage === "number" && !isNaN(input.perPage)) { + queryBuilder.limit(input.perPage); + } + + data = await queryBuilder; + + const countBuilder = db + .select({ count: count() }) + .from(paymentTerms); + + if (finalWhere) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("Error in getPaymentTerms:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } +} + +export async function createPaymentTerm(data: Omit<typeof paymentTerms.$inferInsert, "createdAt">) { + try { + const [created] = await db.insert(paymentTerms).values(data).returning(); + return { data: created }; + } catch (err) { + console.error("Error creating payment term:", err); + return { error: "생성 중 오류가 발생했습니다." }; + } +} + +export async function updatePaymentTerm(code: string, data: Partial<typeof paymentTerms.$inferInsert>) { + try { + const [updated] = await db + .update(paymentTerms) + .set(data) + .where(eq(paymentTerms.code, code)) + .returning(); + return { data: updated }; + } catch (err) { + console.error("Error updating payment term:", err); + return { error: "수정 중 오류가 발생했습니다." }; + } +} + +export async function deletePaymentTerm(code: string) { + try { + await db.delete(paymentTerms).where(eq(paymentTerms.code, code)); + return { success: true }; + } catch (err) { + console.error("Error deleting payment term:", err); + return { error: "삭제 중 오류가 발생했습니다." }; + } +} + diff --git a/lib/payment-terms/table/payment-terms-add-dialog.tsx b/lib/payment-terms/table/payment-terms-add-dialog.tsx new file mode 100644 index 00000000..9aa21485 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-add-dialog.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Plus, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { createPaymentTerm } from "../service"; +import { toast } from "sonner"; + +const createPaymentTermSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().min(1, "설명은 필수입니다."), + isActive: z.boolean().default(true), +}); + +type CreatePaymentTermFormValues = z.infer<typeof createPaymentTermSchema>; + +interface PaymentTermsAddDialogProps { + onSuccess?: () => void; +} + +export function PaymentTermsAddDialog({ onSuccess }: PaymentTermsAddDialogProps) { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const form = useForm<CreatePaymentTermFormValues>({ + resolver: zodResolver(createPaymentTermSchema), + defaultValues: { + code: "", + description: "", + isActive: true, + }, + }); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: CreatePaymentTermFormValues) => { + setIsLoading(true); + try { + const result = await createPaymentTerm(data); + if (result.data) { + toast.success("결제 조건이 추가되었습니다."); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error(result.error || "생성 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("결제 조건 생성 오류:", error); + toast.error("결제 조건 생성에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + 결제 조건 추가 + </Button> + </DialogTrigger> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>결제 조건 추가</DialogTitle> + <DialogDescription> + 새로운 결제 조건을 추가합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel> + 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="결제 조건 코드" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel> + 설명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="결제 조건 설명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "결제 조건 추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-edit-sheet.tsx b/lib/payment-terms/table/payment-terms-edit-sheet.tsx new file mode 100644 index 00000000..b0d105bc --- /dev/null +++ b/lib/payment-terms/table/payment-terms-edit-sheet.tsx @@ -0,0 +1,147 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { updatePaymentTerm } from "../service" +import { paymentTerms } from "@/db/schema/procurementRFQ" + +const updatePaymentTermSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().min(1, "설명은 필수입니다."), + isActive: z.boolean().default(true), +}) + +type UpdatePaymentTermSchema = z.infer<typeof updatePaymentTermSchema> + +interface PaymentTermsEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + data: typeof paymentTerms.$inferSelect + onSuccess: () => void +} + +export function PaymentTermsEditSheet({ + open, + onOpenChange, + data, + onSuccess, +}: PaymentTermsEditSheetProps) { + const form = useForm<UpdatePaymentTermSchema>({ + resolver: zodResolver(updatePaymentTermSchema), + defaultValues: { + code: data.code, + description: data.description, + isActive: data.isActive, + }, + mode: "onChange" + }) + + React.useEffect(() => { + if (data) { + form.reset({ + code: data.code, + description: data.description, + isActive: data.isActive, + }) + } + }, [data, form]) + + async function onSubmit(input: UpdatePaymentTermSchema) { + try { + await updatePaymentTerm(data.code, input) + toast.success("수정이 완료되었습니다.") + onSuccess() + onOpenChange(false) + } catch { + toast.error("수정 중 오류가 발생했습니다.") + } + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>결제 조건 수정</SheetTitle> + <SheetDescription> + 결제 조건 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>코드</FormLabel> + <FormControl> + <Input {...field} disabled /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel>활성화</FormLabel> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + <div className="flex justify-end space-x-2"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button type="submit">저장</Button> + </div> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-table-columns.tsx b/lib/payment-terms/table/payment-terms-table-columns.tsx new file mode 100644 index 00000000..208723f7 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-table-columns.tsx @@ -0,0 +1,102 @@ +import { type ColumnDef, type Row } from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Ellipsis } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; +import { toast } from "sonner"; +import { deletePaymentTerm } from "../service"; + +type PaymentTerm = typeof paymentTerms.$inferSelect; + +interface GetColumnsProps { + setRowAction: (action: { type: string; row: Row<PaymentTerm> }) => void; + onSuccess: () => void; +} + +const handleDelete = async (code: string, onSuccess: () => void) => { + const result = await deletePaymentTerm(code); + if (result.success) { + toast.success("삭제 완료"); + onSuccess(); + } else { + toast.error(result.error || "삭제 실패"); + } +}; + +export function getColumns({ setRowAction, onSuccess }: GetColumnsProps): ColumnDef<PaymentTerm>[] { + return [ + { + id: "code", + header: () => <div>코드</div>, + cell: ({ row }) => <div>{row.original.code}</div>, + enableSorting: true, + enableHiding: false, + }, + { + id: "description", + header: () => <div>설명</div>, + cell: ({ row }) => <div>{row.original.description}</div>, + enableSorting: true, + enableHiding: false, + }, + { + id: "isActive", + header: () => <div>상태</div>, + cell: ({ row }) => ( + <Badge variant={row.original.isActive ? "default" : "secondary"}> + {row.original.isActive ? "활성" : "비활성"} + </Badge> + ), + enableSorting: true, + enableHiding: false, + }, + { + id: "createdAt", + header: () => <div>생성일</div>, + cell: ({ row }) => { + const value = row.original.createdAt; + const date = value ? new Date(value) : null; + return date ? date.toLocaleDateString() : ""; + }, + enableSorting: true, + enableHiding: false, + }, + { + id: "actions", + cell: ({ row }) => ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "edit", row })} + > + 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => handleDelete(row.original.code, onSuccess)} + className="text-destructive" + > + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ), + }, + ]; +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-table-toolbar.tsx b/lib/payment-terms/table/payment-terms-table-toolbar.tsx new file mode 100644 index 00000000..2466a9e4 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-table-toolbar.tsx @@ -0,0 +1,16 @@ +"use client"; + +import * as React from "react"; +import { PaymentTermsAddDialog } from "./payment-terms-add-dialog"; + +interface PaymentTermsTableToolbarProps { + onSuccess?: () => void; +} + +export function PaymentTermsTableToolbar({ onSuccess }: PaymentTermsTableToolbarProps) { + return ( + <div className="flex items-center gap-2"> + <PaymentTermsAddDialog onSuccess={onSuccess} /> + </div> + ); +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-table.tsx b/lib/payment-terms/table/payment-terms-table.tsx new file mode 100644 index 00000000..589acb52 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-table.tsx @@ -0,0 +1,127 @@ +"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 { getColumns } from "./payment-terms-table-columns"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; +import { PaymentTermsTableToolbar } from "./payment-terms-table-toolbar"; +import { toast } from "sonner"; +import { PaymentTermsEditSheet } from "./payment-terms-edit-sheet"; +import { Row } from "@tanstack/react-table"; +import { getPaymentTerms } from "../service"; +import { GetPaymentTermsSchema } from "../validations"; + +interface PaymentTermsTableProps { + promises?: Promise<[{ data: typeof paymentTerms.$inferSelect[]; pageCount: number }] >; +} + +export function PaymentTermsTable({ promises }: PaymentTermsTableProps) { + const [rawData, setRawData] = React.useState<{ data: typeof paymentTerms.$inferSelect[]; pageCount: number }>({ data: [], pageCount: 0 }); + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false); + const [selectedRow, setSelectedRow] = React.useState<typeof paymentTerms.$inferSelect | null>(null); + + React.useEffect(() => { + if (promises) { + promises.then(([result]) => { + setRawData(result); + }); + } else { + // fallback: 클라이언트에서 직접 fetch (CSR) + (async () => { + try { + const result = await getPaymentTerms({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + description: "", + isActive: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + })(); + } + }, [promises]); + + const fetchPaymentTerms = React.useCallback(async (params: Record<string, unknown>) => { + try { + const result = await getPaymentTerms(params as GetPaymentTermsSchema); + return result; + } catch (error) { + console.error("Error fetching payment terms:", error); + throw error; + } + }, []); + + const refreshData = React.useCallback(async () => { + try { + const result = await fetchPaymentTerms({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + description: "", + isActive: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + }, [fetchPaymentTerms]); + + const handleRowAction = async (action: { type: string; row: Row<typeof paymentTerms.$inferSelect> }) => { + if (action.type === "edit") { + setSelectedRow(action.row.original); + setIsEditDialogOpen(true); + } + }; + + const columns = React.useMemo(() => getColumns({ setRowAction: handleRowAction, onSuccess: refreshData }), [refreshData]); + + const { table } = useDataTable({ + data: rawData.data, + columns, + pageCount: rawData.pageCount, + filterFields: [], + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.code), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar table={table} filterFields={[]} shallow={false}> + <PaymentTermsTableToolbar onSuccess={refreshData} /> + </DataTableAdvancedToolbar> + </DataTable> + {isEditDialogOpen && selectedRow && ( + <PaymentTermsEditSheet + open={isEditDialogOpen} + onOpenChange={setIsEditDialogOpen} + data={selectedRow} + onSuccess={refreshData} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/lib/payment-terms/validations.ts b/lib/payment-terms/validations.ts new file mode 100644 index 00000000..6c043d50 --- /dev/null +++ b/lib/payment-terms/validations.ts @@ -0,0 +1,34 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; + +export const SearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (createdAt 기준 내림차순) + sort: getSortingStateParser<typeof paymentTerms>().withDefault([ + { id: "createdAt", desc: true }]), + + // 기존 필드 + code: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); +export type GetPaymentTermsSchema = Awaited<ReturnType<typeof SearchParamsCache.parse>>; + |
