diff --git a/web/src/components/common/ActionButtons.tsx b/web/src/components/common/ActionButtons.tsx new file mode 100644 index 0000000..3d50dde --- /dev/null +++ b/web/src/components/common/ActionButtons.tsx @@ -0,0 +1,168 @@ +/** + * ActionButtons - Reusable action buttons for tables and cards + */ + +import { Eye, Pencil, Trash2, MoreVertical, type LucideIcon } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import clsx from 'clsx'; + +interface ActionButtonProps { + icon: LucideIcon; + label: string; + onClick: () => void; + variant?: 'default' | 'danger' | 'success' | 'warning'; + disabled?: boolean; +} + +const variantClasses = { + default: 'text-gray-500 hover:text-blue-600 hover:bg-blue-50', + danger: 'text-gray-500 hover:text-red-600 hover:bg-red-50', + success: 'text-gray-500 hover:text-green-600 hover:bg-green-50', + warning: 'text-gray-500 hover:text-yellow-600 hover:bg-yellow-50', +}; + +export function ActionButton({ + icon: Icon, + label, + onClick, + variant = 'default', + disabled = false, +}: ActionButtonProps) { + return ( + + ); +} + +interface ActionButtonsProps { + onView?: () => void; + onEdit?: () => void; + onDelete?: () => void; + viewLabel?: string; + editLabel?: string; + deleteLabel?: string; + className?: string; +} + +export function ActionButtons({ + onView, + onEdit, + onDelete, + viewLabel = 'Ver detalle', + editLabel = 'Editar', + deleteLabel = 'Eliminar', + className, +}: ActionButtonsProps) { + return ( +
+ {onView && ( + + )} + {onEdit && ( + + )} + {onDelete && ( + + )} +
+ ); +} + +// Dropdown menu for more actions +interface ActionMenuItem { + icon?: LucideIcon; + label: string; + onClick: () => void; + variant?: 'default' | 'danger'; + disabled?: boolean; +} + +interface ActionMenuProps { + items: ActionMenuItem[]; + className?: string; +} + +export function ActionMenu({ items, className }: ActionMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+ {items.map((item, index) => { + const Icon = item.icon; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/web/src/components/common/ConfirmDialog.tsx b/web/src/components/common/ConfirmDialog.tsx new file mode 100644 index 0000000..5a9fed1 --- /dev/null +++ b/web/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,104 @@ +/** + * ConfirmDialog - Reusable confirmation dialog component + */ + +import { AlertTriangle, Info, AlertCircle } from 'lucide-react'; +import clsx from 'clsx'; +import { Modal, ModalFooter } from './Modal'; + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'danger' | 'warning' | 'info'; + isLoading?: boolean; +} + +const variantConfig = { + danger: { + icon: AlertTriangle, + iconBg: 'bg-red-100', + iconColor: 'text-red-600', + buttonClass: 'bg-red-600 hover:bg-red-700', + }, + warning: { + icon: AlertCircle, + iconBg: 'bg-yellow-100', + iconColor: 'text-yellow-600', + buttonClass: 'bg-yellow-600 hover:bg-yellow-700', + }, + info: { + icon: Info, + iconBg: 'bg-blue-100', + iconColor: 'text-blue-600', + buttonClass: 'bg-blue-600 hover:bg-blue-700', + }, +}; + +export function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title = 'Confirmar acción', + message, + confirmLabel = 'Confirmar', + cancelLabel = 'Cancelar', + variant = 'danger', + isLoading = false, +}: ConfirmDialogProps) { + const config = variantConfig[variant]; + const Icon = config.icon; + + const handleConfirm = () => { + onConfirm(); + }; + + return ( + +
+
+ +
+

{title}

+

{message}

+ + + + +
+
+ ); +} diff --git a/web/src/components/common/DataTable.tsx b/web/src/components/common/DataTable.tsx new file mode 100644 index 0000000..51caca1 --- /dev/null +++ b/web/src/components/common/DataTable.tsx @@ -0,0 +1,209 @@ +/** + * DataTable - Reusable table component with pagination, loading, and empty states + */ + +import { type ReactNode } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import clsx from 'clsx'; +import { LoadingOverlay } from './LoadingSpinner'; +import { EmptyState } from './EmptyState'; + +// Column definition +export interface DataTableColumn { + key: string; + header: string; + width?: string; + align?: 'left' | 'center' | 'right'; + render?: (item: T, index: number) => ReactNode; + className?: string; +} + +// Pagination state +export interface DataTablePagination { + page: number; + limit: number; + total: number; +} + +// Props +interface DataTableProps { + data: T[]; + columns: DataTableColumn[]; + keyField?: keyof T | ((item: T) => string); + isLoading?: boolean; + error?: string | null; + pagination?: DataTablePagination; + onPageChange?: (page: number) => void; + onRowClick?: (item: T) => void; + emptyState?: { + title?: string; + description?: string; + icon?: ReactNode; + action?: ReactNode; + }; + className?: string; + rowClassName?: string | ((item: T, index: number) => string); +} + +export function DataTable({ + data, + columns, + keyField = 'id' as keyof T, + isLoading = false, + error = null, + pagination, + onPageChange, + onRowClick, + emptyState, + className, + rowClassName, +}: DataTableProps) { + const getRowKey = (item: T, index: number): string => { + if (typeof keyField === 'function') { + return keyField(item); + } + const key = item[keyField]; + return key != null ? String(key) : String(index); + }; + + const getRowClassName = (item: T, index: number): string => { + if (typeof rowClassName === 'function') { + return rowClassName(item, index); + } + return rowClassName || ''; + }; + + const alignClasses = { + left: 'text-left', + center: 'text-center', + right: 'text-right', + }; + + // Calculate pagination + const totalPages = pagination + ? Math.ceil(pagination.total / pagination.limit) + : 0; + const startItem = pagination + ? (pagination.page - 1) * pagination.limit + 1 + : 0; + const endItem = pagination + ? Math.min(pagination.page * pagination.limit, pagination.total) + : 0; + + return ( +
+ {/* Loading State */} + {isLoading && } + + {/* Error State */} + {!isLoading && error && ( +
{error}
+ )} + + {/* Empty State */} + {!isLoading && !error && data.length === 0 && ( + + )} + + {/* Table */} + {!isLoading && !error && data.length > 0 && ( + <> +
+ + + + {columns.map((column) => ( + + ))} + + + + {data.map((item, index) => ( + onRowClick(item) : undefined} + > + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.header} +
+ {column.render + ? column.render(item, index) + : String((item as Record)[column.key] ?? '-')} +
+
+ + {/* Pagination */} + {pagination && totalPages > 1 && ( +
+
+ Mostrando {startItem} a {endItem} de {pagination.total} resultados +
+
+ + + Página {pagination.page} de {totalPages} + + +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/components/common/EmptyState.tsx b/web/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..5cd2fe3 --- /dev/null +++ b/web/src/components/common/EmptyState.tsx @@ -0,0 +1,31 @@ +/** + * EmptyState - Component to display when there's no data + */ + +import { FileX } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface EmptyStateProps { + title?: string; + description?: string; + icon?: ReactNode; + action?: ReactNode; +} + +export function EmptyState({ + title = 'No hay datos', + description = 'No se encontraron registros que mostrar.', + icon, + action, +}: EmptyStateProps) { + return ( +
+
+ {icon ?? } +
+

{title}

+

{description}

+ {action} +
+ ); +} diff --git a/web/src/components/common/FormField.tsx b/web/src/components/common/FormField.tsx new file mode 100644 index 0000000..a619e06 --- /dev/null +++ b/web/src/components/common/FormField.tsx @@ -0,0 +1,222 @@ +/** + * FormField - Reusable form field components + */ + +import { forwardRef, type InputHTMLAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react'; +import clsx from 'clsx'; +import type { SelectOption } from '../../types'; + +interface BaseFieldProps { + label?: string; + error?: string; + required?: boolean; + hint?: string; +} + +// Text Input +interface TextInputProps extends InputHTMLAttributes, BaseFieldProps {} + +export const TextInput = forwardRef( + ({ label, error, required, hint, className, id, ...props }, ref) => { + const inputId = id || props.name; + + return ( +
+ {label && ( + + )} + + {hint && !error && ( +

{hint}

+ )} + {error &&

{error}

} +
+ ); + } +); + +TextInput.displayName = 'TextInput'; + +// Select +interface SelectFieldProps extends SelectHTMLAttributes, BaseFieldProps { + options: SelectOption[]; + placeholder?: string; +} + +export const SelectField = forwardRef( + ({ label, error, required, hint, options, placeholder, className, id, ...props }, ref) => { + const selectId = id || props.name; + + return ( +
+ {label && ( + + )} + + {hint && !error && ( +

{hint}

+ )} + {error &&

{error}

} +
+ ); + } +); + +SelectField.displayName = 'SelectField'; + +// Textarea +interface TextareaFieldProps extends TextareaHTMLAttributes, BaseFieldProps {} + +export const TextareaField = forwardRef( + ({ label, error, required, hint, className, id, rows = 3, ...props }, ref) => { + const textareaId = id || props.name; + + return ( +
+ {label && ( + + )} +