diff options
Diffstat (limited to 'lib/procurement-items/table')
8 files changed, 1430 insertions, 0 deletions
diff --git a/lib/procurement-items/table/add-procurement-items-dialog.tsx b/lib/procurement-items/table/add-procurement-items-dialog.tsx new file mode 100644 index 00000000..b2915dc2 --- /dev/null +++ b/lib/procurement-items/table/add-procurement-items-dialog.tsx @@ -0,0 +1,197 @@ +"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { createProcurementItemSchema, CreateProcurementItemSchema } from "../validations"
+import { createProcurementItem } from "../service"
+
+interface AddProcurementItemDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess?: () => void
+}
+
+export function AddProcurementItemDialog({
+ open,
+ onOpenChange,
+ onSuccess
+}: AddProcurementItemDialogProps) {
+ // react-hook-form 세팅
+ const form = useForm<CreateProcurementItemSchema>({
+ resolver: zodResolver(createProcurementItemSchema),
+ defaultValues: {
+ itemCode: "",
+ itemName: "",
+ material: "",
+ specification: "",
+ unit: "",
+ isActive: "Y",
+ },
+ })
+
+ async function onSubmit(data: CreateProcurementItemSchema) {
+ const result = await createProcurementItem({
+ itemCode: data.itemCode,
+ itemName: data.itemName,
+ material: data.material ?? null,
+ specification: data.specification ?? null,
+ unit: data.unit ?? null,
+ isActive: data.isActive || 'Y',
+ })
+
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ onOpenChange(false)
+ onSuccess?.()
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ onOpenChange(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>새 품목 추가</DialogTitle>
+ <DialogDescription>
+ 새 품목 정보를 입력하고 <b>추가</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목코드</FormLabel>
+ <FormControl>
+ <Input placeholder="예: ITEM001" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 강관" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="material"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>재질</FormLabel>
+ <FormControl>
+ <Input placeholder="예: SS400" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="specification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>규격</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 50x50x2.3T" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="unit"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>단위</FormLabel>
+ <FormControl>
+ <Input placeholder="예: EA, M, KG" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isActive"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>활성화여부</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="활성화 여부를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="Y">활성</SelectItem>
+ <SelectItem value="N">비활성</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ {form.formState.isSubmitting ? "추가 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/procurement-items/table/delete-procurement-items-dialog.tsx b/lib/procurement-items/table/delete-procurement-items-dialog.tsx new file mode 100644 index 00000000..a1262a03 --- /dev/null +++ b/lib/procurement-items/table/delete-procurement-items-dialog.tsx @@ -0,0 +1,151 @@ +"use client"
+
+import * as React from "react"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { ProcurementItem } from "@/db/schema/items"
+import { removeProcurementItems } from "../service"
+
+interface DeleteProcurementItemsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ procurementItems: ProcurementItem[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteProcurementItemsDialog({
+ procurementItems,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteProcurementItemsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeProcurementItems({
+ ids: procurementItems.map((item) => item.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("품목이 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({procurementItems.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 실행 취소할 수 없습니다. 선택한{" "}
+ <span className="font-medium">{procurementItems.length}</span>개 품목을
+ 서버에서 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 품목 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({procurementItems.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 실행 취소할 수 없습니다. 선택한{" "}
+ <span className="font-medium">{procurementItems.length}</span>개 품목을
+ 서버에서 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 품목 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/procurement-items/table/import-procurement-items-excel-button.tsx b/lib/procurement-items/table/import-procurement-items-excel-button.tsx new file mode 100644 index 00000000..6a50909e --- /dev/null +++ b/lib/procurement-items/table/import-procurement-items-excel-button.tsx @@ -0,0 +1,247 @@ +"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+
+interface ImportProcurementItemButtonProps {
+ onImportSuccess?: () => void
+}
+
+export function ImportProcurementItemButton({ onImportSuccess }: ImportProcurementItemButtonProps) {
+ const [open, setOpen] = React.useState(false)
+ const [file, setFile] = React.useState<File | null>(null)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [progress, setProgress] = React.useState(0)
+ const [error, setError] = React.useState<string | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0]
+ if (!selectedFile) return
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
+ return
+ }
+
+ setFile(selectedFile)
+ setError(null)
+ }
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setProgress(0)
+ setError(null)
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await file.arrayBuffer()
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(arrayBuffer)
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.")
+ }
+
+ // 헤더 행 찾기
+ let headerRowIndex = 1
+ let headerRow: ExcelJS.Row | undefined
+ let headerValues: (string | null)[] = []
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[]
+ if (!headerRow && values.some(v => v === "품목코드" || v === "itemCode" || v === "item_code")) {
+ headerRowIndex = rowNumber
+ headerRow = row
+ headerValues = [...values]
+ }
+ })
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.")
+ }
+
+ // 컬럼 매핑
+ const columnMap: { [key: string]: number } = {}
+ headerValues.forEach((header, index) => {
+ if (header) {
+ const normalizedHeader = header.toString().toLowerCase()
+ if (normalizedHeader.includes("품목코드") || normalizedHeader.includes("itemcode") || normalizedHeader === "item_code") {
+ columnMap.itemCode = index
+ } else if (normalizedHeader.includes("품목명") || normalizedHeader.includes("itemname") || normalizedHeader === "item_name") {
+ columnMap.itemName = index
+ } else if (normalizedHeader.includes("재질") || normalizedHeader.includes("material")) {
+ columnMap.material = index
+ } else if (normalizedHeader.includes("규격") || normalizedHeader.includes("specification")) {
+ columnMap.specification = index
+ } else if (normalizedHeader.includes("단위") || normalizedHeader.includes("unit")) {
+ columnMap.unit = index
+ } else if (normalizedHeader.includes("활성화") || normalizedHeader.includes("isactive") || normalizedHeader === "is_active") {
+ columnMap.isActive = index
+ }
+ }
+ })
+
+ // 필수 컬럼 확인
+ if (!columnMap.itemCode || !columnMap.itemName) {
+ throw new Error("필수 컬럼(품목코드, 품목명)을 찾을 수 없습니다.")
+ }
+
+ // 데이터 행 처리
+ const importData: any[] = []
+ let successCount = 0
+ let errorCount = 0
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber <= headerRowIndex) return // 헤더 행 건너뜀
+
+ const values = row.values as (string | null | undefined)[]
+
+ const itemData = {
+ itemCode: values[columnMap.itemCode]?.toString().trim(),
+ itemName: values[columnMap.itemName]?.toString().trim(),
+ material: values[columnMap.material]?.toString().trim() || null,
+ specification: values[columnMap.specification]?.toString().trim() || null,
+ unit: values[columnMap.unit]?.toString().trim() || null,
+ isActive: values[columnMap.isActive]?.toString().trim() || 'Y',
+ }
+
+ // 필수 필드 검증
+ if (!itemData.itemCode || !itemData.itemName) {
+ errorCount++
+ return
+ }
+
+ importData.push(itemData)
+ })
+
+ if (importData.length === 0) {
+ throw new Error("가져올 데이터가 없습니다.")
+ }
+
+ setProgress(50)
+
+ // 실제 데이터 저장 처리 (서버 액션 호출)
+ const { importProcurementItemsFromExcel } = await import('../service')
+ const result = await importProcurementItemsFromExcel(importData)
+
+ if (!result.success) {
+ throw new Error(result.message || '가져오기에 실패했습니다.')
+ }
+
+ setProgress(100)
+
+ toast.success(`${result.importedCount}개 품목이 성공적으로 가져오기를 완료했습니다.`)
+
+ // 성공 콜백 호출
+ onImportSuccess?.()
+ setOpen(false)
+
+ } catch (error) {
+ console.error('가져오기 오류:', error)
+ setError(error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.')
+ toast.error('가져오기에 실패했습니다.')
+ } finally {
+ setIsUploading(false)
+ setProgress(0)
+ }
+ }
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setOpen(true)}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ 엑셀 가져오기
+ </Button>
+
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>엑셀 파일에서 품목 가져오기</DialogTitle>
+ <DialogDescription>
+ 템플릿을 다운로드하여 작성한 후 가져오기를 실행하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ className="hidden"
+ />
+ <Button
+ variant="outline"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isUploading}
+ className="w-full"
+ >
+ 파일 선택
+ </Button>
+ {file && (
+ <p className="mt-2 text-sm text-muted-foreground">
+ 선택된 파일: {file.name}
+ </p>
+ )}
+ </div>
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} className="w-full" />
+ <p className="text-sm text-muted-foreground">
+ 처리 중... {progress}%
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <p className="text-sm text-destructive">{error}</p>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "가져오는 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+}
diff --git a/lib/procurement-items/table/procurement-items-excel-template.tsx b/lib/procurement-items/table/procurement-items-excel-template.tsx new file mode 100644 index 00000000..d72af26a --- /dev/null +++ b/lib/procurement-items/table/procurement-items-excel-template.tsx @@ -0,0 +1,101 @@ +import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 품목 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportProcurementItemTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Procurement Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('품목');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '품목코드', key: 'itemCode', width: 15 },
+ { header: '품목명', key: 'itemName', width: 30 },
+ { header: '재질', key: 'material', width: 20 },
+ { header: '규격', key: 'specification', width: 25 },
+ { header: '단위', key: 'unit', width: 10 },
+ { header: '활성화여부', key: 'isActive', width: 12 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 행 추가
+ const sampleRow = worksheet.addRow({
+ itemCode: 'ITEM001',
+ itemName: '강관',
+ material: 'SS400',
+ specification: '50x50x2.3T',
+ unit: 'EA',
+ isActive: 'Y'
+ });
+
+ // 샘플 행 스타일 적용
+ sampleRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFF5F5F5' }
+ };
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 설명 행 추가
+ const descriptionRow = worksheet.addRow([
+ '※ 품목코드: 필수, 중복 불가',
+ '※ 품목명: 필수',
+ '※ 재질: 선택사항',
+ '※ 규격: 선택사항',
+ '※ 단위: 선택사항 (예: EA, M, KG)',
+ '※ 활성화여부: Y(활성) 또는 N(비활성)'
+ ]);
+
+ descriptionRow.font = { italic: true, color: { argb: 'FF666666' } };
+ descriptionRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFFFE0E0' }
+ };
+ });
+
+ // 워크시트 설정
+ worksheet.views = [
+ { state: 'frozen', ySplit: 1 } // 헤더 행 고정
+ ];
+
+ // 파일 저장
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ });
+ saveAs(blob, 'procurement_items_template.xlsx');
+}
diff --git a/lib/procurement-items/table/procurement-items-table-columns.tsx b/lib/procurement-items/table/procurement-items-table-columns.tsx new file mode 100644 index 00000000..b695767a --- /dev/null +++ b/lib/procurement-items/table/procurement-items-table-columns.tsx @@ -0,0 +1,179 @@ +"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { procurementItemsColumnsConfig } from "@/config/procurementItemsColumnsConfig"
+import { ProcurementItem } from "@/db/schema/items"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProcurementItem> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ProcurementItem>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<ProcurementItem> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<ProcurementItem> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ 수정
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<ProcurementItem>[] }
+ const groupMap: Record<string, ColumnDef<ProcurementItem>[]> = {}
+
+ procurementItemsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<ProcurementItem> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal, "KR")
+ }
+
+ if (cfg.id === "isActive") {
+ const value = cell.getValue() as string
+ return (
+ <Badge variant={value === 'Y' ? 'default' : 'secondary'}>
+ {value === 'Y' ? '활성' : '비활성'}
+ </Badge>
+ )
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<ProcurementItem>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+}
diff --git a/lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx b/lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx new file mode 100644 index 00000000..f9bc8805 --- /dev/null +++ b/lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx @@ -0,0 +1,182 @@ +"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileDown, Plus } from "lucide-react"
+import * as ExcelJS from 'exceljs'
+import { saveAs } from "file-saver"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { ProcurementItem } from "@/db/schema/items"
+import { DeleteProcurementItemsDialog } from "./delete-procurement-items-dialog"
+import { AddProcurementItemDialog } from "./add-procurement-items-dialog"
+import { exportProcurementItemTemplate } from "./procurement-items-excel-template"
+import { ImportProcurementItemButton } from "./import-procurement-items-excel-button"
+
+interface ProcurementItemsTableToolbarActionsProps {
+ table: Table<ProcurementItem>
+}
+
+export function ProcurementItemsTableToolbarActions({
+ table
+}: ProcurementItemsTableToolbarActionsProps) {
+ const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const router = useRouter()
+
+ // 가져오기 성공 후 테이블 갱신
+ const handleImportSuccess = () => {
+ router.refresh()
+ }
+
+ // Excel 내보내기 함수
+ const exportTableToExcel = async (
+ table: Table<ProcurementItem>,
+ options: {
+ filename: string;
+ excludeColumns?: string[];
+ sheetName?: string;
+ }
+ ) => {
+ const { filename, excludeColumns = [], sheetName = "품목 목록" } = options;
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Procurement Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // 테이블 데이터 가져오기
+ const data = table.getFilteredRowModel().rows.map(row => row.original);
+
+ // 테이블 헤더 가져오기
+ const headers = table.getAllColumns()
+ .filter(column => !excludeColumns.includes(column.id))
+ .map(column => ({
+ key: column.id,
+ header: column.columnDef.meta?.excelHeader || column.id,
+ }));
+
+ // 헤더 행 추가
+ worksheet.addRow(headers.map(h => h.header));
+
+ // 데이터 행 추가
+ data.forEach((item) => {
+ const rowData = headers.map(header => {
+ const value = item[header.key];
+
+ // 날짜 처리
+ if (header.key === 'createdAt' || header.key === 'updatedAt') {
+ return value instanceof Date ? value.toLocaleDateString('ko-KR') : value;
+ }
+
+ // 활성화 여부 처리
+ if (header.key === 'isActive') {
+ return value === 'Y' ? '활성' : '비활성';
+ }
+
+ return value || '';
+ });
+ worksheet.addRow(rowData);
+ });
+
+ // 컬럼 너비 설정
+ worksheet.columns = headers.map(header => ({
+ key: header.key,
+ width: header.key === 'itemName' || header.key === 'specification' ? 30 : 15
+ }));
+
+ // 파일 저장
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ });
+ saveAs(blob, `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`);
+ };
+
+ // 테이블 내보내기
+ const handleExportToExcel = () => {
+ exportTableToExcel(table, {
+ filename: 'procurement_items',
+ excludeColumns: ['select', 'actions'],
+ sheetName: '품목 목록'
+ });
+ toast.success('엑셀 파일이 다운로드되었습니다.');
+ };
+
+ // 템플릿 다운로드
+ const handleDownloadTemplate = () => {
+ exportProcurementItemTemplate();
+ toast.success('엑셀 템플릿이 다운로드되었습니다.');
+ };
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {/* 추가 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setIsAddDialogOpen(true)}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 품목 추가
+ </Button>
+
+ {/* 엑셀 내보내기 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Download className="mr-2 h-4 w-4" />
+ 내보내기
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExportToExcel}>
+ <FileDown className="mr-2 h-4 w-4" />
+ 현재 목록 내보내기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleDownloadTemplate}>
+ <FileDown className="mr-2 h-4 w-4" />
+ 템플릿 다운로드
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 엑셀 가져오기 버튼 */}
+ <ImportProcurementItemButton onImportSuccess={handleImportSuccess} />
+
+ {/* 선택된 항목들 삭제 버튼 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <DeleteProcurementItemsDialog
+ procurementItems={table.getFilteredSelectedRowModel().rows.map(row => row.original)}
+ showTrigger={true}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ router.refresh()
+ }}
+ />
+ )}
+ </div>
+
+ <AddProcurementItemDialog
+ open={isAddDialogOpen}
+ onOpenChange={setIsAddDialogOpen}
+ onSuccess={() => {
+ setIsAddDialogOpen(false)
+ router.refresh()
+ }}
+ />
+ </>
+ )
+}
diff --git a/lib/procurement-items/table/procurement-items-table.tsx b/lib/procurement-items/table/procurement-items-table.tsx new file mode 100644 index 00000000..a504af40 --- /dev/null +++ b/lib/procurement-items/table/procurement-items-table.tsx @@ -0,0 +1,152 @@ +"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction
+} from "@/types/table"
+
+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 { getProcurementItems } from "../service"
+import { ProcurementItem } from "@/db/schema/items"
+import { getColumns } from "./procurement-items-table-columns"
+import { ProcurementItemsTableToolbarActions } from "./procurement-items-table-toolbar-actions"
+import { UpdateProcurementItemSheet } from "./update-procurement-items-sheet"
+import { DeleteProcurementItemsDialog } from "./delete-procurement-items-dialog"
+
+interface ProcurementItemsTableProps {
+ promises?: Promise<
+ [
+ Awaited<ReturnType<typeof getProcurementItems>>,
+ ]
+ >
+}
+
+export function ProcurementItemsTable({ promises }: ProcurementItemsTableProps) {
+ // 페이지네이션 모드 데이터
+ const paginationData = promises ? React.use(promises) : null
+ const [{ data = [], pageCount = 0 }] = paginationData || [{ data: [], pageCount: 0 }]
+
+ console.log('ProcurementItemsTable data:', data.length, 'items')
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ProcurementItem> | null>(null)
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+ const router = useRouter()
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 필터 필드들
+ const filterFields: DataTableFilterField<ProcurementItem>[] = [
+ {
+ id: "itemCode",
+ label: "품목코드",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ProcurementItem>[] = [
+ {
+ id: "itemCode",
+ label: "품목코드",
+ type: "text",
+ },
+ {
+ id: "itemName",
+ label: "품목명",
+ type: "text",
+ },
+ {
+ id: "material",
+ label: "재질",
+ type: "text",
+ },
+ {
+ id: "specification",
+ label: "규격",
+ type: "text",
+ },
+ {
+ id: "unit",
+ label: "단위",
+ type: "text",
+ },
+ {
+ id: "isActive",
+ label: "활성화여부",
+ type: "text",
+ },
+ ]
+
+ const handleCompactChange = (compact: boolean) => {
+ setIsCompact(compact)
+ }
+
+ // useDataTable 훅 사용
+ const {table} = useDataTable({
+ data: data,
+ pageCount: pageCount,
+ columns,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ pageSize: 10,
+ sorting: [{ id: "createdAt", desc: true }],
+ columnVisibility: {
+ id: false,
+ updatedAt: false,
+ },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="procurementItemsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <ProcurementItemsTableToolbarActions
+ table={table}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateProcurementItemSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ procurementItem={rowAction?.row.original}
+ />
+
+ <DeleteProcurementItemsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ procurementItems={rowAction?.row.original ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null)
+ router.refresh()
+ }}
+ />
+ </>
+ )
+}
diff --git a/lib/procurement-items/table/update-procurement-items-sheet.tsx b/lib/procurement-items/table/update-procurement-items-sheet.tsx new file mode 100644 index 00000000..9cda28ae --- /dev/null +++ b/lib/procurement-items/table/update-procurement-items-sheet.tsx @@ -0,0 +1,221 @@ +"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { ProcurementItem } from "@/db/schema/items"
+import { updateProcurementItemSchema, UpdateProcurementItemSchema } from "../validations"
+import { modifyProcurementItem } from "../service"
+import { Input } from "@/components/ui/input"
+
+interface UpdateProcurementItemSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ procurementItem: ProcurementItem | null
+}
+
+export function UpdateProcurementItemSheet({
+ procurementItem,
+ ...props
+}: UpdateProcurementItemSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ console.log(procurementItem)
+ const form = useForm<UpdateProcurementItemSchema>({
+ resolver: zodResolver(updateProcurementItemSchema),
+ defaultValues: {
+ itemCode: procurementItem?.itemCode ?? "",
+ itemName: procurementItem?.itemName ?? "",
+ material: procurementItem?.material ?? "",
+ specification: procurementItem?.specification ?? "",
+ unit: procurementItem?.unit ?? "",
+ isActive: procurementItem?.isActive ?? "Y",
+ },
+ })
+
+ React.useEffect(() => {
+ if (procurementItem) {
+ form.reset({
+ itemCode: procurementItem.itemCode ?? "",
+ itemName: procurementItem.itemName ?? "",
+ material: procurementItem.material ?? "",
+ specification: procurementItem.specification ?? "",
+ unit: procurementItem.unit ?? "",
+ isActive: procurementItem.isActive ?? "Y",
+ })
+ }
+ }, [procurementItem, form])
+
+ function onSubmit(input: UpdateProcurementItemSchema) {
+ startUpdateTransition(async () => {
+ if (!procurementItem) return
+
+ const { error } = await modifyProcurementItem({
+ id: procurementItem.id,
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ toast.success("품목이 성공적으로 수정되었습니다.")
+ props.onOpenChange?.(false)
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="w-[400px] sm:w-[540px]">
+ <SheetHeader>
+ <SheetTitle>품목 수정</SheetTitle>
+ <SheetDescription>
+ 품목 정보를 수정하고 <b>저장</b> 버튼을 누르세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="mt-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목코드</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="material"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>재질</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="specification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>규격</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="unit"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>단위</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isActive"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>활성화여부</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="Y">활성</SelectItem>
+ <SelectItem value="N">비활성</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter>
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader className="mr-2 size-4 animate-spin" />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+}
|
