summaryrefslogtreecommitdiff
path: root/lib/welding
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-05 01:53:35 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-05 01:53:35 +0000
commit610d3bccf1cb640e2a21df28d8d2a954c2bf337e (patch)
treee7e6d72fecf14ddcff1b5b52263d14119b7c488c /lib/welding
parent15969dfedffc4e215c81d507164bc2bb383974e5 (diff)
(대표님) 변경사항 0604 - OCR 관련 및 drizzle generated sqls
Diffstat (limited to 'lib/welding')
-rw-r--r--lib/welding/repository.ts49
-rw-r--r--lib/welding/service.ts87
-rw-r--r--lib/welding/table/ocr-table-columns.tsx312
-rw-r--r--lib/welding/table/ocr-table-toolbar-actions.tsx297
-rw-r--r--lib/welding/table/ocr-table.tsx143
-rw-r--r--lib/welding/validation.ts36
6 files changed, 924 insertions, 0 deletions
diff --git a/lib/welding/repository.ts b/lib/welding/repository.ts
new file mode 100644
index 00000000..10e64f58
--- /dev/null
+++ b/lib/welding/repository.ts
@@ -0,0 +1,49 @@
+// src/lib/tasks/repository.ts
+import db from "@/db/db";
+import { ocrRows } from "@/db/schema";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectOcrRows(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+ ) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(ocrRows)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+/** 총 개수 count */
+export async function countOcrRows(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(ocrRows).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/welding/service.ts b/lib/welding/service.ts
new file mode 100644
index 00000000..3dce07f8
--- /dev/null
+++ b/lib/welding/service.ts
@@ -0,0 +1,87 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { tagClasses } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { GetOcrRowSchema } from "./validation";
+import { ocrRows } from "@/db/schema";
+import { countOcrRows, selectOcrRows } from "./repository";
+
+export async function getOcrRows(input: GetOcrRowSchema) {
+ // return unstable_cache(
+ // async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: ocrRows,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(ocrRows.reportNo, s),
+ ilike(ocrRows.identificationNo, s),
+ ilike(ocrRows.tagNo, s),
+ ilike(ocrRows.jointNo, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const conditions = [];
+ if (advancedWhere) conditions.push(advancedWhere);
+ if (globalWhere) conditions.push(globalWhere);
+
+ let finalWhere;
+ if (conditions.length > 0) {
+ finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0];
+ }
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(ocrRows[item.id]) : asc(ocrRows[item.id])
+ )
+ : [asc(ocrRows.createdAt)];
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectOcrRows(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countOcrRows(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ // },
+ // [JSON.stringify(input)], // 캐싱 키
+ // {
+ // revalidate: 3600,
+ // tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화
+ // }
+ // )();
+ } \ No newline at end of file
diff --git a/lib/welding/table/ocr-table-columns.tsx b/lib/welding/table/ocr-table-columns.tsx
new file mode 100644
index 00000000..85830405
--- /dev/null
+++ b/lib/welding/table/ocr-table-columns.tsx
@@ -0,0 +1,312 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { ArrowUpDown, Copy, MoreHorizontal } from "lucide-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { toast } from "sonner"
+import { formatDate } from "@/lib/utils"
+import { OcrRow } from "@/db/schema"
+import { type DataTableRowAction } from "@/types/table"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<OcrRow> | null>>
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow>[] {
+ return [
+ // 체크박스 컬럼
+ {
+ 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"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // Report No 컬럼
+ {
+ accessorKey: "reportNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Report No" />
+ ),
+ cell: ({ getValue }) => {
+ const reportNo = getValue() as string
+ return (
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="font-mono text-xs">
+ {reportNo || "N/A"}
+ </Badge>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-6"
+ onClick={() => {
+ navigator.clipboard.writeText(reportNo || "")
+ toast.success("Report No copied to clipboard")
+ }}
+ >
+ <Copy className="size-3" />
+ </Button>
+ </div>
+ )
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+
+ // No 컬럼
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="No" />
+ ),
+ cell: ({ getValue }) => {
+ const no = getValue() as string
+ return (
+ <div className="font-medium">
+ {no || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Identification No 컬럼
+ {
+ accessorKey: "identificationNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Identification No" />
+ ),
+ cell: ({ getValue }) => {
+ const identificationNo = getValue() as string
+ return (
+ <div className="max-w-[200px] truncate font-mono text-sm" title={identificationNo}>
+ {identificationNo || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Tag No 컬럼
+ {
+ accessorKey: "tagNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Tag No" />
+ ),
+ cell: ({ getValue }) => {
+ const tagNo = getValue() as string
+ return (
+ <div className="font-mono text-sm">
+ {tagNo || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Joint No 컬럼
+ {
+ accessorKey: "jointNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Joint No" />
+ ),
+ cell: ({ getValue }) => {
+ const jointNo = getValue() as string
+ return (
+ <div className="font-mono text-sm">
+ {jointNo || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Joint Type 컬럼
+ {
+ accessorKey: "jointType",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Joint Type" />
+ ),
+ cell: ({ getValue }) => {
+ const jointType = getValue() as string
+ return (
+ <Badge variant={jointType === "B" ? "default" : "secondary"}>
+ {jointType || "N/A"}
+ </Badge>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Welding Date 컬럼
+ {
+ accessorKey: "weldingDate",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Welding Date" />
+ ),
+ cell: ({ getValue }) => {
+ const weldingDate = getValue() as string
+ return (
+ <div className="text-sm">
+ {weldingDate || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Confidence 컬럼
+ {
+ accessorKey: "confidence",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Confidence" />
+ ),
+ cell: ({ getValue }) => {
+ const confidence = parseFloat(getValue() as string) || 0
+ const percentage = Math.round(confidence * 100)
+
+ let variant: "default" | "secondary" | "destructive" = "default"
+ if (percentage < 70) variant = "destructive"
+ else if (percentage < 90) variant = "secondary"
+
+ return (
+ <Badge variant={variant}>
+ {percentage}%
+ </Badge>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Source Table 컬럼
+ {
+ accessorKey: "sourceTable",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Table" />
+ ),
+ cell: ({ getValue }) => {
+ const sourceTable = getValue() as number
+ return (
+ <div className="text-center">
+ <Badge variant="outline" className="text-xs">
+ T{sourceTable}
+ </Badge>
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Source Row 컬럼
+ {
+ accessorKey: "sourceRow",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Row" />
+ ),
+ cell: ({ getValue }) => {
+ const sourceRow = getValue() as number
+ return (
+ <div className="text-center text-sm text-muted-foreground">
+ {sourceRow}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Created At 컬럼
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="생성일" />
+ ),
+ cell: ({ cell }) => {
+ const date = cell.getValue() as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Actions 컬럼
+ {
+ 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"
+ >
+ <MoreHorizontal className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onClick={() => {
+ const rowData = row.original
+ navigator.clipboard.writeText(JSON.stringify(rowData, null, 2))
+ toast.success("Row data copied to clipboard")
+ }}
+ >
+ <Copy className="mr-2 size-4" aria-hidden="true" />
+ Copy Row Data
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "view", row })
+ }}
+ >
+ View Details
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "delete", row })
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx
new file mode 100644
index 00000000..001b21cb
--- /dev/null
+++ b/lib/welding/table/ocr-table-toolbar-actions.tsx
@@ -0,0 +1,297 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, Upload, FileText, Loader2 } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import { OcrRow } from "@/db/schema"
+
+interface OcrTableToolbarActionsProps {
+ table: Table<OcrRow>
+}
+
+interface UploadProgress {
+ stage: string
+ progress: number
+ message: string
+}
+
+export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [uploadProgress, setUploadProgress] = React.useState<UploadProgress | null>(null)
+ const [isUploadDialogOpen, setIsUploadDialogOpen] = React.useState(false)
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (file) {
+ setSelectedFile(file)
+ }
+ }
+
+ const validateFile = (file: File): string | null => {
+ // 파일 크기 체크 (10MB)
+ if (file.size > 10 * 1024 * 1024) {
+ return "File size must be less than 10MB"
+ }
+
+ // 파일 타입 체크
+ const allowedTypes = [
+ 'application/pdf',
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/tiff',
+ 'image/bmp'
+ ]
+
+ if (!allowedTypes.includes(file.type)) {
+ return "Only PDF and image files (JPG, PNG, TIFF, BMP) are supported"
+ }
+
+ return null
+ }
+
+ const uploadFile = async () => {
+ if (!selectedFile) {
+ toast.error("Please select a file first")
+ return
+ }
+
+ const validationError = validateFile(selectedFile)
+ if (validationError) {
+ toast.error(validationError)
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setUploadProgress({
+ stage: "preparing",
+ progress: 10,
+ message: "Preparing file upload..."
+ })
+
+ const formData = new FormData()
+ formData.append('file', selectedFile)
+
+ setUploadProgress({
+ stage: "uploading",
+ progress: 30,
+ message: "Uploading file and processing..."
+ })
+
+ const response = await fetch('/api/ocr/enhanced', {
+ method: 'POST',
+ body: formData,
+ })
+
+ setUploadProgress({
+ stage: "processing",
+ progress: 70,
+ message: "Analyzing document with OCR..."
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'OCR processing failed')
+ }
+
+ const result = await response.json()
+
+ setUploadProgress({
+ stage: "saving",
+ progress: 90,
+ message: "Saving results to database..."
+ })
+
+ if (result.success) {
+ setUploadProgress({
+ stage: "complete",
+ progress: 100,
+ message: "OCR processing completed successfully!"
+ })
+
+ toast.success(
+ `OCR completed! Extracted ${result.metadata.totalRows} rows from ${result.metadata.totalTables} tables`,
+ {
+ description: result.warnings?.length
+ ? `Warnings: ${result.warnings.join(', ')}`
+ : undefined
+ }
+ )
+
+ // 성공 후 다이얼로그 닫기 및 상태 초기화
+ setTimeout(() => {
+ setIsUploadDialogOpen(false)
+ setSelectedFile(null)
+ setUploadProgress(null)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+
+ // 테이블 새로고침
+ window.location.reload()
+ }, 2000)
+
+ } else {
+ throw new Error(result.error || 'Unknown error occurred')
+ }
+
+ } catch (error) {
+ console.error('Error uploading file:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while processing the file'
+ )
+ setUploadProgress(null)
+ } finally {
+ setIsUploading(false)
+ }
+ }
+
+ const resetUpload = () => {
+ setSelectedFile(null)
+ setUploadProgress(null)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* OCR 업로드 다이얼로그 */}
+ <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
+ <DialogTrigger asChild>
+ <Button variant="samsung" size="sm" className="gap-2">
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Upload OCR</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Upload Document for OCR</DialogTitle>
+ <DialogDescription>
+ Upload a PDF or image file to extract table data using OCR technology.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 파일 선택 */}
+ <div className="space-y-2">
+ <Label htmlFor="file-upload">Select File</Label>
+ <Input
+ ref={fileInputRef}
+ id="file-upload"
+ type="file"
+ accept=".pdf,.jpg,.jpeg,.png,.tiff,.bmp"
+ onChange={handleFileSelect}
+ disabled={isUploading}
+ />
+ <p className="text-xs text-muted-foreground">
+ Supported formats: PDF, JPG, PNG, TIFF, BMP (Max 10MB)
+ </p>
+ </div>
+
+ {/* 선택된 파일 정보 */}
+ {selectedFile && (
+ <div className="rounded-lg border p-3 space-y-2">
+ <div className="flex items-center gap-2">
+ <FileText className="size-4 text-muted-foreground" />
+ <span className="text-sm font-medium">{selectedFile.name}</span>
+ </div>
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
+ <span>Size: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span>
+ <span>Type: {selectedFile.type}</span>
+ </div>
+ </div>
+ )}
+
+ {/* 업로드 진행상황 */}
+ {uploadProgress && (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium">Processing...</span>
+ <Badge variant={uploadProgress.stage === "complete" ? "default" : "secondary"}>
+ {uploadProgress.stage}
+ </Badge>
+ </div>
+ <Progress value={uploadProgress.progress} className="h-2" />
+ <p className="text-xs text-muted-foreground">
+ {uploadProgress.message}
+ </p>
+ </div>
+ )}
+
+ {/* 액션 버튼들 */}
+ <div className="flex justify-end gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (isUploading) {
+ // 업로드 중이면 취소 불가능하다는 메시지
+ toast.warning("Cannot cancel while processing. Please wait...")
+ } else {
+ setIsUploadDialogOpen(false)
+ resetUpload()
+ }
+ }}
+ disabled={isUploading && uploadProgress?.stage !== "complete"}
+ >
+ {isUploading ? "Close" : "Cancel"}
+ </Button>
+ <Button
+ size="sm"
+ onClick={uploadFile}
+ disabled={!selectedFile || isUploading}
+ className="gap-2"
+ >
+ {isUploading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Upload className="size-4" aria-hidden="true" />
+ )}
+ {isUploading ? "Processing..." : "Start OCR"}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ {/* Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "OCR Result",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/welding/table/ocr-table.tsx b/lib/welding/table/ocr-table.tsx
new file mode 100644
index 00000000..91af1c67
--- /dev/null
+++ b/lib/welding/table/ocr-table.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+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 { OcrTableToolbarActions } from "./ocr-table-toolbar-actions"
+import { getColumns } from "./ocr-table-columns"
+import { OcrRow } from "@/db/schema"
+import { getOcrRows } from "../service"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getOcrRows>>,
+ ]
+ >
+}
+
+export function OcrTable({ promises }: ItemsTableProps) {
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<OcrRow> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<OcrRow>[] = [
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<OcrRow>[] = [
+ {
+ id: "reportNo",
+ label: "Report No",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
+ id: "no",
+ label: "No",
+ type: "text",
+ // group: "Basic Info",
+ },
+
+
+ {
+ id: "identificationNo",
+ label: "Identification No",
+ type: "text",
+ // group: "Metadata",a
+ },
+ {
+ id: "tagNo",
+ label: "Tag No",
+ type: "text",
+ // group: "Metadata",
+ },
+ {
+ id: "jointNo",
+ label: "Joint No",
+ type: "text",
+ // group: "Metadata",
+ },
+ {
+ id: "weldingDate",
+ label: "Welding Date",
+ type: "date",
+ // group: "Metadata",
+ },
+ {
+ id: "createdAt",
+ label: "생성일",
+ type: "date",
+ // group: "Metadata",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <OcrTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+}
diff --git a/lib/welding/validation.ts b/lib/welding/validation.ts
new file mode 100644
index 00000000..fe5b2cbb
--- /dev/null
+++ b/lib/welding/validation.ts
@@ -0,0 +1,36 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { OcrRow } from "@/db/schema";
+
+
+// 검색 파라미터 캐시 정의
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 확장된 타입으로 정렬 파서 사용
+ sort: getSortingStateParser<OcrRow>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 기존 필터 옵션들
+ code: parseAsString.withDefault(""),
+ label: parseAsString.withDefault(""),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
+
+// 타입 내보내기
+export type GetOcrRowSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;