From 09fa1ccabcfb07608fd0c2b27973551b7c38a2a3 Mon Sep 17 00:00:00 2001 From: investify ctrl Date: Wed, 30 Sep 2026 11:22:39 +0100 Subject: [PATCH] feat: implement dashboard widgets and customs documentation features - Add ResizableWidget component for dashboard layout system (#866) - Implement useCustomsDocs hook for customs documentation management (#869) - Create CustomsDocumentationManager interface for cross-border shipping (#868) Features: - Resizable and draggable dashboard widgets with maximize/minimize - Comprehensive customs document upload and validation - Document status tracking (pending/approved/rejected) - Real-time validation of required customs documents - Support for multiple document types (invoices, permits, declarations) Closes #866 Closes #868 Closes #869 --- components/dashboard/ResizableWidget.tsx | 255 ++++++++++++ .../shipments/CustomsDocumentationManager.tsx | 393 ++++++++++++++++++ hooks/useCustomsDocs.ts | 356 ++++++++++++++++ 3 files changed, 1004 insertions(+) create mode 100644 components/dashboard/ResizableWidget.tsx create mode 100644 components/shipments/CustomsDocumentationManager.tsx create mode 100644 hooks/useCustomsDocs.ts diff --git a/components/dashboard/ResizableWidget.tsx b/components/dashboard/ResizableWidget.tsx new file mode 100644 index 0000000..6323da0 --- /dev/null +++ b/components/dashboard/ResizableWidget.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react'; +import { GripVertical, Maximize2, Minimize2, X } from 'lucide-react'; +import { clsx } from 'clsx'; + +export interface WidgetSize { + width: number; + height: number; +} + +export interface WidgetPosition { + x: number; + y: number; +} + +export interface ResizableWidgetProps { + id: string; + title: string; + children: ReactNode; + defaultSize?: WidgetSize; + minSize?: WidgetSize; + maxSize?: WidgetSize; + defaultPosition?: WidgetPosition; + isResizable?: boolean; + isDraggable?: boolean; + isClosable?: boolean; + isMaximizable?: boolean; + className?: string; + onClose?: (id: string) => void; + onResize?: (id: string, size: WidgetSize) => void; + onMove?: (id: string, position: WidgetPosition) => void; + onMaximize?: (id: string, isMaximized: boolean) => void; +} + +/** + * ResizableWidget — Dashboard widget with resize, drag, maximize, and close capabilities. + */ +export function ResizableWidget({ + id, + title, + children, + defaultSize = { width: 400, height: 300 }, + minSize = { width: 200, height: 150 }, + maxSize = { width: 1200, height: 800 }, + defaultPosition = { x: 0, y: 0 }, + isResizable = true, + isDraggable = true, + isClosable = true, + isMaximizable = true, + className, + onClose, + onResize, + onMove, + onMaximize, +}: ResizableWidgetProps) { + const [size, setSize] = useState(defaultSize); + const [position, setPosition] = useState(defaultPosition); + const [isMaximized, setIsMaximized] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + + const widgetRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const resizeStartRef = useRef<{ + x: number; + y: number; + width: number; + height: number; + direction: string; + } | null>(null); + const savedStateRef = useRef<{ size: WidgetSize; position: WidgetPosition } | null>(null); + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + if (!isDraggable || isMaximized) return; + e.preventDefault(); + setIsDragging(true); + dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y }; + }, + [isDraggable, isMaximized, position] + ); + + const handleDragMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !dragStartRef.current) return; + const newPosition = { + x: e.clientX - dragStartRef.current.x, + y: e.clientY - dragStartRef.current.y, + }; + setPosition(newPosition); + onMove?.(id, newPosition); + }, + [isDragging, id, onMove] + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + dragStartRef.current = null; + }, []); + + const handleResizeStart = useCallback( + (e: React.MouseEvent, direction: string) => { + if (!isResizable || isMaximized) return; + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + resizeStartRef.current = { + x: e.clientX, + y: e.clientY, + width: size.width, + height: size.height, + direction, + }; + }, + [isResizable, isMaximized, size] + ); + + const handleResizeMove = useCallback( + (e: MouseEvent) => { + if (!isResizing || !resizeStartRef.current) return; + const { x, y, width, height, direction } = resizeStartRef.current; + const deltaX = e.clientX - x; + const deltaY = e.clientY - y; + + let newWidth = width; + let newHeight = height; + + if (direction.includes('e')) { + newWidth = Math.max(minSize.width, Math.min(maxSize.width, width + deltaX)); + } + if (direction.includes('s')) { + newHeight = Math.max(minSize.height, Math.min(maxSize.height, height + deltaY)); + } + + const newSize = { width: newWidth, height: newHeight }; + setSize(newSize); + onResize?.(id, newSize); + }, + [isResizing, id, minSize, maxSize, onResize] + ); + + const handleResizeEnd = useCallback(() => { + setIsResizing(false); + resizeStartRef.current = null; + }, []); + + const handleMaximize = useCallback(() => { + if (!isMaximizable) return; + if (isMaximized) { + if (savedStateRef.current) { + setSize(savedStateRef.current.size); + setPosition(savedStateRef.current.position); + savedStateRef.current = null; + } + setIsMaximized(false); + onMaximize?.(id, false); + } else { + savedStateRef.current = { size, position }; + setSize({ width: window.innerWidth - 40, height: window.innerHeight - 40 }); + setPosition({ x: 20, y: 20 }); + setIsMaximized(true); + onMaximize?.(id, true); + } + }, [id, isMaximizable, isMaximized, size, position, onMaximize]); + + const handleClose = useCallback(() => { + if (!isClosable) return; + onClose?.(id); + }, [id, isClosable, onClose]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + handleDragMove(e); + handleResizeMove(e); + }; + + const handleMouseUp = () => { + handleDragEnd(); + handleResizeEnd(); + }; + + if (isDragging || isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, isResizing, handleDragMove, handleResizeMove, handleDragEnd, handleResizeEnd]); + + return ( +
+
+
+ {isDraggable && !isMaximized && } +

{title}

+
+
+ {isMaximizable && ( + + )} + {isClosable && ( + + )} +
+
+
+ {children} +
+ {isResizable && !isMaximized && ( +
handleResizeStart(e, 'se')} + /> + )} +
+ ); +} diff --git a/components/shipments/CustomsDocumentationManager.tsx b/components/shipments/CustomsDocumentationManager.tsx new file mode 100644 index 0000000..d22d774 --- /dev/null +++ b/components/shipments/CustomsDocumentationManager.tsx @@ -0,0 +1,393 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { + FileText, + Upload, + Trash2, + CheckCircle, + XCircle, + AlertTriangle, + Download, + Eye, + RefreshCw, +} from 'lucide-react'; +import { clsx } from 'clsx'; +import { + useCustomsDocs, + type CustomsDocument, + type CustomsDocumentUpload, +} from '@/hooks/useCustomsDocs'; + +interface CustomsDocumentationManagerProps { + shipmentId: string; + readonly?: boolean; + autoValidate?: boolean; + className?: string; +} + +const DOCUMENT_TYPE_LABELS: Record = { + commercial_invoice: 'Commercial Invoice', + packing_list: 'Packing List', + certificate_of_origin: 'Certificate of Origin', + export_declaration: 'Export Declaration', + import_permit: 'Import Permit', + customs_declaration: 'Customs Declaration', + other: 'Other', +}; + +const DOCUMENT_TYPE_OPTIONS: CustomsDocument['documentType'][] = [ + 'commercial_invoice', + 'packing_list', + 'certificate_of_origin', + 'export_declaration', + 'import_permit', + 'customs_declaration', + 'other', +]; + +/** + * CustomsDocumentationManager — Interface for managing cross-border customs documentation. + * Provides upload, validation, status tracking, and compliance checking for customs documents. + */ +export function CustomsDocumentationManager({ + shipmentId, + readonly = false, + autoValidate = true, + className, +}: CustomsDocumentationManagerProps) { + const [selectedType, setSelectedType] = useState('commercial_invoice'); + const [documentNumber, setDocumentNumber] = useState(''); + const [notes, setNotes] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + + const { + documents, + isLoading, + isError, + validationResult, + isValidating, + isUploading, + uploadProgress, + uploadDocument, + deleteDocument, + updateDocumentStatus, + validateDocuments, + refreshDocuments, + getDocumentsByType, + } = useCustomsDocs({ shipmentId, autoValidate }); + + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + } + }, []); + + const handleUpload = useCallback(async () => { + if (!selectedFile) return; + + const upload: CustomsDocumentUpload = { + shipmentId, + documentType: selectedType, + file: selectedFile, + documentNumber: documentNumber || undefined, + notes: notes || undefined, + }; + + try { + await uploadDocument(upload); + setSelectedFile(null); + setDocumentNumber(''); + setNotes(''); + } catch (error) { + console.error('Upload failed:', error); + } + }, [selectedFile, shipmentId, selectedType, documentNumber, notes, uploadDocument]); + + const handleDelete = useCallback( + async (documentId: string) => { + if (confirm('Are you sure you want to delete this document?')) { + await deleteDocument(documentId); + } + }, + [deleteDocument] + ); + + const handleStatusUpdate = useCallback( + async (documentId: string, status: CustomsDocument['status']) => { + await updateDocumentStatus(documentId, status); + }, + [updateDocumentStatus] + ); + + const getStatusIcon = (status: CustomsDocument['status']) => { + switch (status) { + case 'approved': + return ; + case 'rejected': + return ; + default: + return ; + } + }; + + const getStatusBadge = (status: CustomsDocument['status']) => { + const styles = { + pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + approved: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + rejected: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + }; + + return ( + + {getStatusIcon(status)} + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }; + + return ( +
+ {/* Header */} +
+
+
+

+ Customs Documentation +

+

+ Manage cross-border shipping documents and compliance +

+
+
+ + +
+
+
+ +
+ {/* Validation Results */} + {validationResult && ( +
+
+ {validationResult.isValid ? ( + + ) : ( + + )} +
+

+ {validationResult.isValid ? 'All Required Documents Submitted' : 'Documentation Incomplete'} +

+ {validationResult.missingDocuments.length > 0 && ( +
+

Missing Documents:

+
    + {validationResult.missingDocuments.map((type) => ( +
  • {DOCUMENT_TYPE_LABELS[type]}
  • + ))} +
+
+ )} + {validationResult.warnings.length > 0 && ( +
+

Warnings:

+
    + {validationResult.warnings.map((warning, idx) => ( +
  • {warning}
  • + ))} +
+
+ )} + {validationResult.errors.length > 0 && ( +
+

Errors:

+
    + {validationResult.errors.map((error, idx) => ( +
  • {error}
  • + ))} +
+
+ )} +
+
+
+ )} + + {/* Upload Form */} + {!readonly && ( +
+

Upload Document

+
+
+ + +
+
+ + setDocumentNumber(e.target.value)} + placeholder="e.g., INV-2024-001" + className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-900 dark:text-white" + /> +
+
+ +