summaryrefslogtreecommitdiff
path: root/lib/welding/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/welding/table')
-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
3 files changed, 752 insertions, 0 deletions
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>
+ </>
+ )
+}