From 765a63900438fddb2ebe9a7b1aaa0698ab4bb477 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 09:02:57 -0600 Subject: [PATCH] refactor(frontend): Extract common components and types - Create src/types/ with organized type files: - api.types.ts, auth.types.ts, construction.types.ts - estimates.types.ts, hse.types.ts, common.types.ts - Create src/utils/ with utilities: - formatters.ts (formatCurrency, formatDate, etc.) - validators.ts (isValidEmail, isValidRFC, etc.) - constants.ts (status options, color maps) - Create src/components/common/ with reusable components: - DataTable, Modal, ConfirmDialog - FormField (TextInput, SelectField, TextareaField) - StatusBadge, SearchInput, PageHeader - ActionButtons, LoadingSpinner, EmptyState - Refactor pages to use common components: - FraccionamientosPage: 385 -> 285 lines - PresupuestosPage: 412 -> 297 lines - IncidentesPage: 741 -> 253 lines Co-Authored-By: Claude Opus 4.5 --- web/src/components/common/ActionButtons.tsx | 168 ++++ web/src/components/common/ConfirmDialog.tsx | 104 +++ web/src/components/common/DataTable.tsx | 209 +++++ web/src/components/common/EmptyState.tsx | 31 + web/src/components/common/FormField.tsx | 222 ++++++ web/src/components/common/LoadingSpinner.tsx | 45 ++ web/src/components/common/Modal.tsx | 123 +++ web/src/components/common/PageHeader.tsx | 73 ++ web/src/components/common/SearchInput.tsx | 70 ++ web/src/components/common/StatusBadge.tsx | 87 ++ web/src/components/common/index.ts | 36 + web/src/pages/admin/hse/IncidentesPage.tsx | 750 +++--------------- .../admin/presupuestos/PresupuestosPage.tsx | 520 +++++------- .../admin/proyectos/FraccionamientosPage.tsx | 486 +++++------- web/src/types/api.types.ts | 50 ++ web/src/types/auth.types.ts | 56 ++ web/src/types/common.types.ts | 163 ++++ web/src/types/construction.types.ts | 276 +++++++ web/src/types/estimates.types.ts | 259 ++++++ web/src/types/hse.types.ts | 213 +++++ web/src/types/index.ts | 137 ++++ web/src/utils/constants.ts | 221 ++++++ web/src/utils/formatters.ts | 215 +++++ web/src/utils/index.ts | 76 ++ web/src/utils/validators.ts | 215 +++++ 25 files changed, 3576 insertions(+), 1229 deletions(-) create mode 100644 web/src/components/common/ActionButtons.tsx create mode 100644 web/src/components/common/ConfirmDialog.tsx create mode 100644 web/src/components/common/DataTable.tsx create mode 100644 web/src/components/common/EmptyState.tsx create mode 100644 web/src/components/common/FormField.tsx create mode 100644 web/src/components/common/LoadingSpinner.tsx create mode 100644 web/src/components/common/Modal.tsx create mode 100644 web/src/components/common/PageHeader.tsx create mode 100644 web/src/components/common/SearchInput.tsx create mode 100644 web/src/components/common/StatusBadge.tsx create mode 100644 web/src/components/common/index.ts create mode 100644 web/src/types/api.types.ts create mode 100644 web/src/types/auth.types.ts create mode 100644 web/src/types/common.types.ts create mode 100644 web/src/types/construction.types.ts create mode 100644 web/src/types/estimates.types.ts create mode 100644 web/src/types/hse.types.ts create mode 100644 web/src/types/index.ts create mode 100644 web/src/utils/constants.ts create mode 100644 web/src/utils/formatters.ts create mode 100644 web/src/utils/index.ts create mode 100644 web/src/utils/validators.ts 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 && ( + + )} +