diff --git a/web/src/components/common/DatePicker.tsx b/web/src/components/common/DatePicker.tsx new file mode 100644 index 0000000..92392ad --- /dev/null +++ b/web/src/components/common/DatePicker.tsx @@ -0,0 +1,432 @@ +/** + * DatePicker - Date selection component + * G-013: Date picker with range support and localization + */ + +import { useState, useRef, useEffect, useCallback } from 'react'; +import clsx from 'clsx'; + +export interface DatePickerProps { + /** Selected date */ + value?: Date | null; + /** Callback when date changes */ + onChange: (date: Date | null) => void; + /** Minimum selectable date */ + minDate?: Date; + /** Maximum selectable date */ + maxDate?: Date; + /** Placeholder text */ + placeholder?: string; + /** Date format for display */ + format?: string; + /** Disabled state */ + disabled?: boolean; + /** Error message */ + error?: string; + /** Label text */ + label?: string; + /** Helper text */ + helperText?: string; + /** Allow clearing the date */ + clearable?: boolean; + /** Additional CSS classes */ + className?: string; +} + +// Spanish month and day names +const MONTHS_ES = [ + 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre' +]; + +const DAYS_ES = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']; + +export function DatePicker({ + value, + onChange, + minDate, + maxDate, + placeholder = 'Seleccionar fecha', + disabled = false, + error, + label, + helperText, + clearable = true, + className, +}: DatePickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [viewDate, setViewDate] = useState(value || new Date()); + const containerRef = useRef(null); + + // Format date for display + const formatDate = (date: Date): string => { + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + return `${day}/${month}/${year}`; + }; + + // Get calendar days for current month view + const getCalendarDays = useCallback(() => { + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + + const startOffset = firstDay.getDay(); // Day of week (0-6) + const totalDays = lastDay.getDate(); + + const days: Array<{ date: Date; isCurrentMonth: boolean }> = []; + + // Previous month days + const prevMonthLastDay = new Date(year, month, 0).getDate(); + for (let i = startOffset - 1; i >= 0; i--) { + days.push({ + date: new Date(year, month - 1, prevMonthLastDay - i), + isCurrentMonth: false, + }); + } + + // Current month days + for (let i = 1; i <= totalDays; i++) { + days.push({ + date: new Date(year, month, i), + isCurrentMonth: true, + }); + } + + // Next month days to fill the grid + const remaining = 42 - days.length; // 6 rows * 7 days + for (let i = 1; i <= remaining; i++) { + days.push({ + date: new Date(year, month + 1, i), + isCurrentMonth: false, + }); + } + + return days; + }, [viewDate]); + + // Check if a date is selectable + const isDateSelectable = (date: Date): boolean => { + if (minDate && date < minDate) return false; + if (maxDate && date > maxDate) return false; + return true; + }; + + // Check if a date is selected + const isDateSelected = (date: Date): boolean => { + if (!value) return false; + return ( + date.getDate() === value.getDate() && + date.getMonth() === value.getMonth() && + date.getFullYear() === value.getFullYear() + ); + }; + + // Check if a date is today + const isToday = (date: Date): boolean => { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); + }; + + // Navigate months + const goToPrevMonth = () => { + setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1)); + }; + + const goToNextMonth = () => { + setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1)); + }; + + // Handle date selection + const handleDateClick = (date: Date) => { + if (!isDateSelectable(date)) return; + onChange(date); + setIsOpen(false); + }; + + // Handle clear + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(null); + }; + + // Close on click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Update view date when value changes + useEffect(() => { + if (value) { + setViewDate(value); + } + }, [value]); + + const days = getCalendarDays(); + + return ( +
+ {/* Label */} + {label && ( + + )} + + {/* Input trigger */} +
!disabled && setIsOpen(!isOpen)} + className={clsx( + 'flex items-center gap-2 px-3 py-2 rounded-md border transition-colors cursor-pointer', + disabled + ? 'bg-background-muted border-border cursor-not-allowed opacity-60' + : isOpen + ? 'border-primary ring-2 ring-primary/20' + : error + ? 'border-danger' + : 'border-border hover:border-foreground-muted dark:border-border dark:hover:border-foreground-muted', + 'bg-background dark:bg-background' + )} + > + + + + {value ? formatDate(value) : placeholder} + + + {clearable && value && !disabled && ( + + )} +
+ + {/* Helper text */} + {helperText && !error && ( +

+ {helperText} +

+ )} + + {/* Error message */} + {error && ( +

{error}

+ )} + + {/* Calendar dropdown */} + {isOpen && ( +
+ {/* Header */} +
+ + + + {MONTHS_ES[viewDate.getMonth()]} {viewDate.getFullYear()} + + + +
+ + {/* Day names */} +
+ {DAYS_ES.map((day) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar grid */} +
+ {days.map(({ date, isCurrentMonth }, index) => { + const selectable = isDateSelectable(date); + const selected = isDateSelected(date); + const today = isToday(date); + + return ( + + ); + })} +
+ + {/* Today button */} +
+ +
+
+ )} +
+ ); +} + +/** + * DateRangePicker - Select a date range + */ +export interface DateRangePickerProps { + startDate: Date | null; + endDate: Date | null; + onRangeChange: (start: Date | null, end: Date | null) => void; + minDate?: Date; + maxDate?: Date; + disabled?: boolean; + error?: string; + label?: string; + className?: string; +} + +export function DateRangePicker({ + startDate, + endDate, + onRangeChange, + minDate, + maxDate, + disabled = false, + error, + label, + className, +}: DateRangePickerProps) { + return ( +
+ {label && ( + + )} + +
+ onRangeChange(date, endDate)} + maxDate={endDate || maxDate} + minDate={minDate} + placeholder="Fecha inicio" + disabled={disabled} + className="flex-1" + /> + + + + onRangeChange(startDate, date)} + minDate={startDate || minDate} + maxDate={maxDate} + placeholder="Fecha fin" + disabled={disabled} + className="flex-1" + /> +
+ + {error && ( +

{error}

+ )} +
+ ); +} + +// Icons +function CalendarIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ChevronLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ChevronRightIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function XIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export default DatePicker; diff --git a/web/src/components/common/Dropdown.tsx b/web/src/components/common/Dropdown.tsx new file mode 100644 index 0000000..36f7017 --- /dev/null +++ b/web/src/components/common/Dropdown.tsx @@ -0,0 +1,292 @@ +/** + * Dropdown - Dropdown menu component with positioning + * G-011: Accessible dropdown menus and popovers + */ + +import { useState, useRef, useEffect, ReactNode, createContext, useContext } from 'react'; +import clsx from 'clsx'; + +// Context for dropdown state +interface DropdownContextValue { + isOpen: boolean; + close: () => void; +} + +const DropdownContext = createContext(null); + +function useDropdownContext() { + const context = useContext(DropdownContext); + if (!context) { + throw new Error('Dropdown components must be used within a Dropdown'); + } + return context; +} + +// Types +type DropdownPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; + +interface DropdownProps { + children: ReactNode; + className?: string; +} + +interface DropdownTriggerProps { + children: ReactNode; + asChild?: boolean; + className?: string; +} + +interface DropdownContentProps { + children: ReactNode; + placement?: DropdownPlacement; + align?: 'start' | 'end'; + className?: string; + minWidth?: number; +} + +interface DropdownItemProps { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + danger?: boolean; + icon?: ReactNode; + className?: string; +} + +/** + * Dropdown container component + */ +export function Dropdown({ children, className }: DropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const close = () => setIsOpen(false); + const toggle = () => setIsOpen((prev) => !prev); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + close(); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Close on escape key + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + close(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + } + + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [isOpen]); + + return ( + +
+ {/* Inject toggle function to trigger */} + {Array.isArray(children) + ? children.map((child, index) => { + if (index === 0 && typeof child === 'object' && child !== null) { + return ( +
+ {child} +
+ ); + } + return child; + }) + : children} +
+
+ ); +} + +/** + * Dropdown trigger button + */ +export function DropdownTrigger({ children, className }: DropdownTriggerProps) { + return ( +
+ {children} +
+ ); +} + +/** + * Dropdown content container + */ +export function DropdownContent({ + children, + placement = 'bottom-start', + className, + minWidth = 180, +}: DropdownContentProps) { + const { isOpen } = useDropdownContext(); + + if (!isOpen) return null; + + const placementClasses: Record = { + 'bottom-start': 'top-full left-0 mt-1', + 'bottom-end': 'top-full right-0 mt-1', + 'top-start': 'bottom-full left-0 mb-1', + 'top-end': 'bottom-full right-0 mb-1', + }; + + return ( +
+ {children} +
+ ); +} + +/** + * Dropdown menu item + */ +export function DropdownItem({ + children, + onClick, + disabled = false, + danger = false, + icon, + className, +}: DropdownItemProps) { + const { close } = useDropdownContext(); + + const handleClick = () => { + if (disabled) return; + onClick?.(); + close(); + }; + + return ( + + ); +} + +/** + * Dropdown separator + */ +export function DropdownSeparator({ className }: { className?: string }) { + return ( +
+ ); +} + +/** + * Dropdown label (non-interactive) + */ +export function DropdownLabel({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +/** + * Simple Menu component (pre-composed dropdown) + */ +interface MenuProps { + trigger: ReactNode; + items: Array<{ + label: string; + onClick: () => void; + icon?: ReactNode; + disabled?: boolean; + danger?: boolean; + separator?: boolean; + }>; + placement?: DropdownPlacement; + className?: string; +} + +export function Menu({ trigger, items, placement = 'bottom-end', className }: MenuProps) { + return ( + + {trigger} + + {items.map((item, index) => ( +
+ {item.separator && } + {!item.separator && ( + + {item.label} + + )} +
+ ))} +
+
+ ); +} + +export default Dropdown; diff --git a/web/src/components/common/ErrorBoundary.tsx b/web/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..b449565 --- /dev/null +++ b/web/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,224 @@ +/** + * ErrorBoundary - React error boundary component + * G-003: Catches JavaScript errors in child components + */ + +import React, { Component, ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; + /** Custom fallback UI */ + fallback?: ReactNode; + /** Custom fallback render function with error info */ + fallbackRender?: (props: { error: Error; resetError: () => void }) => ReactNode; + /** Callback when error is caught */ + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; + /** Callback when error is reset */ + onReset?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error('ErrorBoundary caught an error:', error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + resetError = (): void => { + this.props.onReset?.(); + this.setState({ hasError: false, error: null }); + }; + + render(): ReactNode { + if (this.state.hasError && this.state.error) { + // Custom fallback render function + if (this.props.fallbackRender) { + return this.props.fallbackRender({ + error: this.state.error, + resetError: this.resetError, + }); + } + + // Custom fallback component + if (this.props.fallback) { + return this.props.fallback; + } + + // Default fallback UI + return ( + + ); + } + + return this.props.children; + } +} + +/** + * Default error fallback UI + */ +interface DefaultErrorFallbackProps { + error: Error; + resetError: () => void; +} + +function DefaultErrorFallback({ error, resetError }: DefaultErrorFallbackProps) { + return ( +
+
+
+ + + +
+ +

+ Algo salió mal +

+ +

+ Ha ocurrido un error inesperado. Por favor, intenta de nuevo. +

+ + {import.meta.env.DEV && ( +
+ + Detalles del error + +
+              {error.message}
+              {error.stack && `\n\n${error.stack}`}
+            
+
+ )} + + +
+
+ ); +} + +/** + * Higher-order component to wrap a component with ErrorBoundary + */ +export function withErrorBoundary

( + WrappedComponent: React.ComponentType

, + errorBoundaryProps?: Omit +): React.FC

{ + const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + const ComponentWithErrorBoundary: React.FC

= (props) => ( + + + + ); + + ComponentWithErrorBoundary.displayName = `withErrorBoundary(${displayName})`; + + return ComponentWithErrorBoundary; +} + +/** + * Error fallback component for page-level errors + */ +export function PageErrorFallback({ + error, + resetError, +}: { + error: Error; + resetError: () => void; +}) { + return ( +

+
+
+ + + +
+ +

+ Error de aplicación +

+ +

+ Lo sentimos, ha ocurrido un error inesperado. Nuestro equipo ha sido + notificado y estamos trabajando para solucionarlo. +

+ + {import.meta.env.DEV && ( +
+ + Información técnica + +
+              {error.message}
+              {error.stack && `\n\n${error.stack}`}
+            
+
+ )} + +
+ + +
+
+
+ ); +} + +export default ErrorBoundary; diff --git a/web/src/components/common/FileUpload.tsx b/web/src/components/common/FileUpload.tsx new file mode 100644 index 0000000..e87a4b3 --- /dev/null +++ b/web/src/components/common/FileUpload.tsx @@ -0,0 +1,366 @@ +/** + * FileUpload - File upload component with drag & drop + * G-012: File upload with preview, validation, and progress + */ + +import { useState, useRef, useCallback, ChangeEvent, DragEvent } from 'react'; +import clsx from 'clsx'; + +export interface FileUploadProps { + /** Accepted file types (e.g., "image/*", ".pdf,.doc") */ + accept?: string; + /** Allow multiple files */ + multiple?: boolean; + /** Max file size in bytes */ + maxSize?: number; + /** Max number of files */ + maxFiles?: number; + /** Callback when files are selected */ + onFilesSelected: (files: File[]) => void; + /** Callback when files are rejected */ + onFilesRejected?: (errors: FileError[]) => void; + /** Callback for upload progress */ + onUploadProgress?: (progress: number) => void; + /** Show file preview */ + showPreview?: boolean; + /** Custom label */ + label?: string; + /** Helper text */ + helperText?: string; + /** Disabled state */ + disabled?: boolean; + /** Error message */ + error?: string; + /** Additional CSS classes */ + className?: string; +} + +export interface FileError { + file: File; + type: 'size' | 'type' | 'count'; + message: string; +} + +interface FilePreview { + file: File; + preview: string | null; +} + +export function FileUpload({ + accept, + multiple = false, + maxSize = 10 * 1024 * 1024, // 10MB default + maxFiles = 10, + onFilesSelected, + onFilesRejected, + showPreview = true, + label = 'Arrastra archivos aquí o haz clic para seleccionar', + helperText, + disabled = false, + error, + className, +}: FileUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const [files, setFiles] = useState([]); + const inputRef = useRef(null); + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const validateFiles = useCallback( + (fileList: FileList | File[]): { valid: File[]; errors: FileError[] } => { + const valid: File[] = []; + const errors: FileError[] = []; + const fileArray = Array.from(fileList); + + // Check max files + if (fileArray.length > maxFiles) { + fileArray.slice(maxFiles).forEach((file) => { + errors.push({ + file, + type: 'count', + message: `Máximo ${maxFiles} archivos permitidos`, + }); + }); + } + + fileArray.slice(0, maxFiles).forEach((file) => { + // Check file size + if (file.size > maxSize) { + errors.push({ + file, + type: 'size', + message: `El archivo excede el tamaño máximo de ${formatFileSize(maxSize)}`, + }); + return; + } + + // Check file type if accept is specified + if (accept) { + const acceptedTypes = accept.split(',').map((t) => t.trim()); + const fileType = file.type; + const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); + + const isAccepted = acceptedTypes.some((type) => { + if (type.startsWith('.')) { + return fileExtension === type.toLowerCase(); + } + if (type.endsWith('/*')) { + return fileType.startsWith(type.replace('/*', '/')); + } + return fileType === type; + }); + + if (!isAccepted) { + errors.push({ + file, + type: 'type', + message: `Tipo de archivo no permitido: ${file.type || fileExtension}`, + }); + return; + } + } + + valid.push(file); + }); + + return { valid, errors }; + }, + [accept, maxSize, maxFiles] + ); + + const createPreviews = (fileList: File[]): FilePreview[] => { + return fileList.map((file) => { + const preview = file.type.startsWith('image/') + ? URL.createObjectURL(file) + : null; + return { file, preview }; + }); + }; + + const handleFiles = useCallback( + (fileList: FileList | File[]) => { + const { valid, errors } = validateFiles(fileList); + + if (errors.length > 0 && onFilesRejected) { + onFilesRejected(errors); + } + + if (valid.length > 0) { + const previews = createPreviews(valid); + setFiles((prev) => (multiple ? [...prev, ...previews] : previews)); + onFilesSelected(valid); + } + }, + [validateFiles, onFilesSelected, onFilesRejected, multiple] + ); + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + if (!disabled) { + setIsDragging(true); + } + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + if (disabled) return; + + const { files: droppedFiles } = e.dataTransfer; + if (droppedFiles.length > 0) { + handleFiles(droppedFiles); + } + }; + + const handleInputChange = (e: ChangeEvent) => { + const { files: selectedFiles } = e.target; + if (selectedFiles && selectedFiles.length > 0) { + handleFiles(selectedFiles); + } + // Reset input + if (inputRef.current) { + inputRef.current.value = ''; + } + }; + + const handleClick = () => { + if (!disabled && inputRef.current) { + inputRef.current.click(); + } + }; + + const removeFile = (index: number) => { + setFiles((prev) => { + const newFiles = [...prev]; + const removed = newFiles.splice(index, 1); + // Revoke preview URL to prevent memory leaks + if (removed[0]?.preview) { + URL.revokeObjectURL(removed[0].preview); + } + return newFiles; + }); + }; + + return ( +
+ {/* Drop zone */} +
+ + +
+
+ +
+ +
+ {label} +
+ + {helperText && ( +
+ {helperText} +
+ )} + +
+ Máximo {formatFileSize(maxSize)} por archivo + {multiple && ` • Hasta ${maxFiles} archivos`} +
+
+
+ + {/* Error message */} + {error && ( +

{error}

+ )} + + {/* File previews */} + {showPreview && files.length > 0 && ( +
+ {files.map((item, index) => ( +
+ {/* Preview thumbnail */} + {item.preview ? ( + {item.file.name} + ) : ( +
+ +
+ )} + + {/* File info */} +
+

+ {item.file.name} +

+

+ {formatFileSize(item.file.size)} +

+
+ + {/* Remove button */} + +
+ ))} +
+ )} +
+ ); +} + +// Icons +function UploadIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function FileIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function XIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export default FileUpload; diff --git a/web/src/components/common/Pagination.tsx b/web/src/components/common/Pagination.tsx new file mode 100644 index 0000000..d63acba --- /dev/null +++ b/web/src/components/common/Pagination.tsx @@ -0,0 +1,289 @@ +/** + * Pagination - Component for paginated data navigation + * G-010: Reusable pagination with multiple display modes + */ + +import clsx from 'clsx'; + +export interface PaginationProps { + /** Current page (1-indexed) */ + currentPage: number; + /** Total number of pages */ + totalPages: number; + /** Total number of items */ + totalItems?: number; + /** Items per page */ + pageSize?: number; + /** Callback when page changes */ + onPageChange: (page: number) => void; + /** Callback when page size changes */ + onPageSizeChange?: (size: number) => void; + /** Available page sizes */ + pageSizeOptions?: number[]; + /** Show page size selector */ + showPageSize?: boolean; + /** Show total items info */ + showTotal?: boolean; + /** Number of page buttons to show */ + siblingCount?: number; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Additional CSS classes */ + className?: string; + /** Disabled state */ + disabled?: boolean; +} + +const sizeClasses = { + sm: 'h-7 min-w-[28px] text-xs', + md: 'h-9 min-w-[36px] text-sm', + lg: 'h-11 min-w-[44px] text-base', +}; + +const iconSizes = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5', +}; + +export function Pagination({ + currentPage, + totalPages, + totalItems, + pageSize = 10, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 20, 50, 100], + showPageSize = false, + showTotal = true, + siblingCount = 1, + size = 'md', + className, + disabled = false, +}: PaginationProps) { + // Generate page numbers to display + const getPageNumbers = (): (number | 'ellipsis')[] => { + const pages: (number | 'ellipsis')[] = []; + + // Always show first page + pages.push(1); + + // Calculate range around current page + const leftSibling = Math.max(2, currentPage - siblingCount); + const rightSibling = Math.min(totalPages - 1, currentPage + siblingCount); + + // Add ellipsis if gap after first page + if (leftSibling > 2) { + pages.push('ellipsis'); + } + + // Add pages around current + for (let i = leftSibling; i <= rightSibling; i++) { + if (i !== 1 && i !== totalPages) { + pages.push(i); + } + } + + // Add ellipsis if gap before last page + if (rightSibling < totalPages - 1) { + pages.push('ellipsis'); + } + + // Always show last page if more than 1 page + if (totalPages > 1) { + pages.push(totalPages); + } + + return pages; + }; + + const handlePageChange = (page: number) => { + if (page >= 1 && page <= totalPages && page !== currentPage && !disabled) { + onPageChange(page); + } + }; + + const pageNumbers = getPageNumbers(); + + // Calculate showing range + const startItem = totalItems ? (currentPage - 1) * pageSize + 1 : 0; + const endItem = totalItems ? Math.min(currentPage * pageSize, totalItems) : 0; + + return ( +
+ {/* Total info */} + {showTotal && totalItems !== undefined && ( +
+ Mostrando{' '} + + {startItem}-{endItem} + {' '} + de{' '} + + {totalItems} + {' '} + resultados +
+ )} + +
+ {/* Page size selector */} + {showPageSize && onPageSizeChange && ( +
+ + Mostrar + + +
+ )} + + {/* Page navigation */} + +
+
+ ); +} + +/** + * Simple pagination (just prev/next) + */ +export function SimplePagination({ + currentPage, + totalPages, + onPageChange, + disabled = false, + className, +}: Pick) { + return ( +
+ + + + Página {currentPage} de {totalPages} + + + +
+ ); +} + +// Icons +function ChevronLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ChevronRightIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export default Pagination; diff --git a/web/src/components/common/Skeleton.test.tsx b/web/src/components/common/Skeleton.test.tsx index f4fd856..bf30a56 100644 --- a/web/src/components/common/Skeleton.test.tsx +++ b/web/src/components/common/Skeleton.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { Skeleton, SkeletonText, diff --git a/web/src/components/common/index.ts b/web/src/components/common/index.ts index 7021b0f..4772bc6 100644 --- a/web/src/components/common/index.ts +++ b/web/src/components/common/index.ts @@ -45,3 +45,29 @@ export type { DataTableColumn, DataTablePagination } from './DataTable'; // Toast Notifications export { ToastContainer, ToastItem } from './Toast'; + +// Error Handling +export { ErrorBoundary, withErrorBoundary, PageErrorFallback } from './ErrorBoundary'; + +// Navigation & Pagination +export { Pagination, SimplePagination } from './Pagination'; +export type { PaginationProps } from './Pagination'; + +// Dropdown & Menus +export { + Dropdown, + DropdownTrigger, + DropdownContent, + DropdownItem, + DropdownSeparator, + DropdownLabel, + Menu, +} from './Dropdown'; + +// File Upload +export { FileUpload } from './FileUpload'; +export type { FileUploadProps, FileError } from './FileUpload'; + +// Date Picker +export { DatePicker, DateRangePicker } from './DatePicker'; +export type { DatePickerProps, DateRangePickerProps } from './DatePicker'; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index 4d38b05..eec288d 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -7,3 +7,7 @@ export * from './useHSE'; export * from './useProgress'; export * from './useFinance'; export * from './useToast'; + +// Utility hooks +export { useDebounce, useDebouncedCallback, useDebounceWithImmediate } from './useDebounce'; +export { useLocalStorage, useSessionStorage } from './useLocalStorage'; diff --git a/web/src/hooks/useDebounce.test.ts b/web/src/hooks/useDebounce.test.ts new file mode 100644 index 0000000..43fb095 --- /dev/null +++ b/web/src/hooks/useDebounce.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for useDebounce hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebounce, useDebouncedCallback } from './useDebounce'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('useDebounce value', () => { + it('should return initial value immediately', () => { + const { result } = renderHook(() => useDebounce('test', 300)); + + expect(result.current).toBe('test'); + }); + + it('should debounce value changes', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 300), + { initialProps: { value: 'initial' } } + ); + + expect(result.current).toBe('initial'); + + // Change value + rerender({ value: 'changed' }); + + // Value should not change immediately + expect(result.current).toBe('initial'); + + // Advance timers + act(() => { + vi.advanceTimersByTime(300); + }); + + // Now value should be updated + expect(result.current).toBe('changed'); + }); + + it('should reset timer on rapid changes', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 300), + { initialProps: { value: 'a' } } + ); + + // Rapid changes + rerender({ value: 'b' }); + act(() => { + vi.advanceTimersByTime(100); + }); + + rerender({ value: 'c' }); + act(() => { + vi.advanceTimersByTime(100); + }); + + rerender({ value: 'd' }); + act(() => { + vi.advanceTimersByTime(100); + }); + + // Still showing initial value + expect(result.current).toBe('a'); + + // Advance full delay + act(() => { + vi.advanceTimersByTime(300); + }); + + // Should show last value + expect(result.current).toBe('d'); + }); + + it('should use custom delay', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 500), + { initialProps: { value: 'initial' } } + ); + + rerender({ value: 'changed' }); + + // Advance less than delay + act(() => { + vi.advanceTimersByTime(400); + }); + expect(result.current).toBe('initial'); + + // Advance past delay + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe('changed'); + }); + }); + + describe('useDebouncedCallback', () => { + it('should debounce callback execution', () => { + const callback = vi.fn(); + const { result } = renderHook(() => useDebouncedCallback(callback, 300)); + + // Call multiple times + act(() => { + result.current('a'); + result.current('b'); + result.current('c'); + }); + + // Callback should not be called yet + expect(callback).not.toHaveBeenCalled(); + + // Advance timer + act(() => { + vi.advanceTimersByTime(300); + }); + + // Callback should be called once with last args + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('c'); + }); + + it('should maintain stable reference', () => { + const callback = vi.fn(); + const { result, rerender } = renderHook( + ({ cb }) => useDebouncedCallback(cb, 300), + { initialProps: { cb: callback } } + ); + + const firstRef = result.current; + rerender({ cb: callback }); + const secondRef = result.current; + + expect(firstRef).toBe(secondRef); + }); + }); +}); diff --git a/web/src/hooks/useDebounce.ts b/web/src/hooks/useDebounce.ts new file mode 100644 index 0000000..b27cadd --- /dev/null +++ b/web/src/hooks/useDebounce.ts @@ -0,0 +1,104 @@ +/** + * useDebounce - Hook for debouncing values + * G-006: Utility hook for search inputs, form validation, etc. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +/** + * Debounces a value by delaying updates until after a specified delay + * @param value - The value to debounce + * @param delay - Delay in milliseconds (default: 300ms) + * @returns The debounced value + */ +export function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +/** + * Returns a debounced callback function + * @param callback - The function to debounce + * @param delay - Delay in milliseconds (default: 300ms) + * @returns Debounced callback function + */ +export function useDebouncedCallback unknown>( + callback: T, + delay: number = 300 +): (...args: Parameters) => void { + const timeoutRef = useRef | null>(null); + const callbackRef = useRef(callback); + + // Keep callback ref updated + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return useCallback( + (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }, + [delay] + ); +} + +/** + * Debounces a value with immediate option + * @param value - The value to debounce + * @param delay - Delay in milliseconds + * @param immediate - If true, trigger on the leading edge instead of trailing + */ +export function useDebounceWithImmediate( + value: T, + delay: number = 300, + immediate: boolean = false +): T { + const [debouncedValue, setDebouncedValue] = useState(value); + const isFirstRender = useRef(true); + + useEffect(() => { + if (immediate && isFirstRender.current) { + setDebouncedValue(value); + isFirstRender.current = false; + return; + } + + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay, immediate]); + + return debouncedValue; +} + +export default useDebounce; diff --git a/web/src/hooks/useLocalStorage.test.ts b/web/src/hooks/useLocalStorage.test.ts new file mode 100644 index 0000000..f9228fb --- /dev/null +++ b/web/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for useLocalStorage hook + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useLocalStorage, useSessionStorage } from './useLocalStorage'; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('should return initial value when localStorage is empty', () => { + const { result } = renderHook(() => useLocalStorage('key', 'initial')); + + expect(result.current[0]).toBe('initial'); + }); + + it('should return stored value from localStorage', () => { + localStorageMock.setItem('key', JSON.stringify('stored')); + + const { result } = renderHook(() => useLocalStorage('key', 'initial')); + + expect(result.current[0]).toBe('stored'); + }); + + it('should update localStorage when value changes', () => { + const { result } = renderHook(() => useLocalStorage('key', 'initial')); + + act(() => { + result.current[1]('updated'); + }); + + expect(result.current[0]).toBe('updated'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('key', JSON.stringify('updated')); + }); + + it('should support function updater', () => { + const { result } = renderHook(() => useLocalStorage('counter', 0)); + + act(() => { + result.current[1]((prev) => prev + 1); + }); + + expect(result.current[0]).toBe(1); + + act(() => { + result.current[1]((prev) => prev + 1); + }); + + expect(result.current[0]).toBe(2); + }); + + it('should remove value from localStorage', () => { + const { result } = renderHook(() => useLocalStorage('key', 'initial')); + + act(() => { + result.current[1]('stored'); + }); + + act(() => { + result.current[2](); // removeValue + }); + + expect(result.current[0]).toBe('initial'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('key'); + }); + + it('should handle objects', () => { + const initialValue = { name: 'John', age: 30 }; + const { result } = renderHook(() => useLocalStorage('user', initialValue)); + + expect(result.current[0]).toEqual(initialValue); + + act(() => { + result.current[1]({ name: 'Jane', age: 25 }); + }); + + expect(result.current[0]).toEqual({ name: 'Jane', age: 25 }); + }); + + it('should handle arrays', () => { + const { result } = renderHook(() => useLocalStorage('items', [])); + + act(() => { + result.current[1](['a', 'b', 'c']); + }); + + expect(result.current[0]).toEqual(['a', 'b', 'c']); + }); +}); + +describe('useSessionStorage', () => { + // Similar tests for sessionStorage + it('should work with sessionStorage', () => { + const sessionStorageMock = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMock, + writable: true, + }); + + const { result } = renderHook(() => useSessionStorage('key', 'initial')); + + expect(result.current[0]).toBe('initial'); + }); +}); diff --git a/web/src/hooks/useLocalStorage.ts b/web/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..d88f2af --- /dev/null +++ b/web/src/hooks/useLocalStorage.ts @@ -0,0 +1,169 @@ +/** + * useLocalStorage - Hook for persisting state in localStorage + * G-007: Utility hook for user preferences, form drafts, etc. + */ + +import { useState, useEffect, useCallback } from 'react'; + +type SetValue = T | ((prevValue: T) => T); + +/** + * Syncs state with localStorage + * @param key - localStorage key + * @param initialValue - Initial/default value + * @returns [storedValue, setValue, removeValue] + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: SetValue) => void, () => void] { + // Get from localStorage or use initial value + const readValue = useCallback((): T => { + if (typeof window === 'undefined') { + return initialValue; + } + + try { + const item = window.localStorage.getItem(key); + return item ? (JSON.parse(item) as T) : initialValue; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }, [key, initialValue]); + + const [storedValue, setStoredValue] = useState(readValue); + + // Return a wrapped version of useState's setter function + const setValue = useCallback( + (value: SetValue) => { + if (typeof window === 'undefined') { + console.warn( + `Tried setting localStorage key "${key}" even though environment is not a browser` + ); + return; + } + + try { + // Allow value to be a function + const newValue = value instanceof Function ? value(storedValue) : value; + + // Save to localStorage + window.localStorage.setItem(key, JSON.stringify(newValue)); + + // Save state + setStoredValue(newValue); + + // Dispatch storage event for other tabs/windows + window.dispatchEvent( + new StorageEvent('storage', { + key, + newValue: JSON.stringify(newValue), + }) + ); + } catch (error) { + console.warn(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + // Remove value from localStorage + const removeValue = useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.removeItem(key); + setStoredValue(initialValue); + + window.dispatchEvent( + new StorageEvent('storage', { + key, + newValue: null, + }) + ); + } catch (error) { + console.warn(`Error removing localStorage key "${key}":`, error); + } + }, [key, initialValue]); + + // Listen for changes in other tabs/windows + useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === key && event.newValue !== null) { + try { + setStoredValue(JSON.parse(event.newValue) as T); + } catch { + setStoredValue(initialValue); + } + } else if (event.key === key && event.newValue === null) { + setStoredValue(initialValue); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, [key, initialValue]); + + return [storedValue, setValue, removeValue]; +} + +/** + * useSessionStorage - Same as useLocalStorage but with sessionStorage + */ +export function useSessionStorage( + key: string, + initialValue: T +): [T, (value: SetValue) => void, () => void] { + const readValue = useCallback((): T => { + if (typeof window === 'undefined') { + return initialValue; + } + + try { + const item = window.sessionStorage.getItem(key); + return item ? (JSON.parse(item) as T) : initialValue; + } catch (error) { + console.warn(`Error reading sessionStorage key "${key}":`, error); + return initialValue; + } + }, [key, initialValue]); + + const [storedValue, setStoredValue] = useState(readValue); + + const setValue = useCallback( + (value: SetValue) => { + if (typeof window === 'undefined') { + return; + } + + try { + const newValue = value instanceof Function ? value(storedValue) : value; + window.sessionStorage.setItem(key, JSON.stringify(newValue)); + setStoredValue(newValue); + } catch (error) { + console.warn(`Error setting sessionStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + const removeValue = useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + try { + window.sessionStorage.removeItem(key); + setStoredValue(initialValue); + } catch (error) { + console.warn(`Error removing sessionStorage key "${key}":`, error); + } + }, [key, initialValue]); + + return [storedValue, setValue, removeValue]; +} + +export default useLocalStorage; diff --git a/web/src/services/apiClient.ts b/web/src/services/apiClient.ts new file mode 100644 index 0000000..3b91e75 --- /dev/null +++ b/web/src/services/apiClient.ts @@ -0,0 +1,223 @@ +/** + * API Client - Centralized HTTP client with interceptors + * G-004: Request/response interceptors, auth handling, error transformation + */ + +import axios, { + AxiosInstance, + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; + +// API Configuration +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3021/api'; +const API_TIMEOUT = 30000; // 30 seconds + +// Token storage keys +const ACCESS_TOKEN_KEY = 'access_token'; +const REFRESH_TOKEN_KEY = 'refresh_token'; + +// API Error interface +export interface ApiError { + message: string; + statusCode: number; + error?: string; + details?: Record; +} + +// Pagination response interface +export interface PaginatedResponse { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; +} + +/** + * Create axios instance with base configuration + */ +const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + timeout: API_TIMEOUT, + headers: { + 'Content-Type': 'application/json', + }, +}); + +/** + * Request interceptor - Add auth token to requests + */ +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem(ACCESS_TOKEN_KEY); + + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + + // Log requests in development + if (import.meta.env.DEV) { + console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.data || ''); + } + + return config; + }, + (error: AxiosError) => { + return Promise.reject(error); + } +); + +/** + * Response interceptor - Handle responses and errors + */ +apiClient.interceptors.response.use( + (response: AxiosResponse) => { + // Log responses in development + if (import.meta.env.DEV) { + console.log(`[API] Response ${response.status}:`, response.data); + } + + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // Handle 401 Unauthorized - Attempt token refresh + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + + if (refreshToken) { + const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { + refreshToken, + }); + + const { accessToken, refreshToken: newRefreshToken } = response.data; + + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + if (newRefreshToken) { + localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken); + } + + // Retry original request with new token + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + } + + return apiClient(originalRequest); + } + } catch (refreshError) { + // Refresh failed - clear tokens and redirect to login + clearAuthTokens(); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + // Transform error for consistent handling + const apiError = transformError(error); + return Promise.reject(apiError); + } +); + +/** + * Transform axios error to ApiError + */ +function transformError(error: AxiosError): ApiError { + if (error.response) { + // Server responded with error + return { + message: error.response.data?.message || 'Error del servidor', + statusCode: error.response.status, + error: error.response.data?.error, + details: error.response.data?.details, + }; + } + + if (error.request) { + // Request made but no response + return { + message: 'No se pudo conectar con el servidor', + statusCode: 0, + error: 'NETWORK_ERROR', + }; + } + + // Request setup error + return { + message: error.message || 'Error desconocido', + statusCode: 0, + error: 'REQUEST_ERROR', + }; +} + +/** + * Token management utilities + */ +export function setAuthTokens(accessToken: string, refreshToken?: string): void { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + if (refreshToken) { + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + } +} + +export function getAccessToken(): string | null { + return localStorage.getItem(ACCESS_TOKEN_KEY); +} + +export function clearAuthTokens(): void { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); +} + +export function isAuthenticated(): boolean { + return !!getAccessToken(); +} + +/** + * API helper methods + */ +export const api = { + get: (url: string, config?: object) => + apiClient.get(url, config).then((res) => res.data), + + post: (url: string, data?: unknown, config?: object) => + apiClient.post(url, data, config).then((res) => res.data), + + put: (url: string, data?: unknown, config?: object) => + apiClient.put(url, data, config).then((res) => res.data), + + patch: (url: string, data?: unknown, config?: object) => + apiClient.patch(url, data, config).then((res) => res.data), + + delete: (url: string, config?: object) => + apiClient.delete(url, config).then((res) => res.data), +}; + +/** + * Paginated request helper + */ +export async function getPaginated( + url: string, + params?: { + page?: number; + limit?: number; + sort?: string; + order?: 'asc' | 'desc'; + search?: string; + [key: string]: unknown; + } +): Promise> { + const response = await apiClient.get>(url, { params }); + return response.data; +} + +export default apiClient;