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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 09:02:57 -06:00
parent b93f4c5797
commit 765a639004
25 changed files with 3576 additions and 1229 deletions

View File

@ -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 (
<button
type="button"
className={clsx(
'p-2 rounded-lg transition-colors',
variantClasses[variant],
disabled && 'opacity-50 cursor-not-allowed'
)}
title={label}
onClick={onClick}
disabled={disabled}
>
<Icon className="w-4 h-4" />
</button>
);
}
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 (
<div className={clsx('flex items-center gap-1', className)}>
{onView && (
<ActionButton
icon={Eye}
label={viewLabel}
onClick={onView}
variant="default"
/>
)}
{onEdit && (
<ActionButton
icon={Pencil}
label={editLabel}
onClick={onEdit}
variant="default"
/>
)}
{onDelete && (
<ActionButton
icon={Trash2}
label={deleteLabel}
onClick={onDelete}
variant="danger"
/>
)}
</div>
);
}
// 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<HTMLDivElement>(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 (
<div className={clsx('relative', className)} ref={menuRef}>
<button
type="button"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg"
onClick={() => setIsOpen(!isOpen)}
>
<MoreVertical className="w-4 h-4" />
</button>
{isOpen && (
<div className="absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border py-1 z-10">
{items.map((item, index) => {
const Icon = item.icon;
return (
<button
key={index}
type="button"
className={clsx(
'w-full px-4 py-2 text-left text-sm flex items-center gap-2',
item.variant === 'danger'
? 'text-red-600 hover:bg-red-50'
: 'text-gray-700 hover:bg-gray-50',
item.disabled && 'opacity-50 cursor-not-allowed'
)}
onClick={() => {
if (!item.disabled) {
item.onClick();
setIsOpen(false);
}
}}
disabled={item.disabled}
>
{Icon && <Icon className="w-4 h-4" />}
{item.label}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@ -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 (
<Modal
isOpen={isOpen}
onClose={onClose}
size="sm"
showCloseButton={false}
closeOnOverlayClick={!isLoading}
closeOnEscape={!isLoading}
>
<div className="flex flex-col items-center text-center">
<div
className={clsx(
'w-12 h-12 rounded-full flex items-center justify-center mb-4',
config.iconBg
)}
>
<Icon className={clsx('w-6 h-6', config.iconColor)} />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-6">{message}</p>
<ModalFooter className="w-full">
<button
type="button"
className="flex-1 px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50 disabled:opacity-50"
onClick={onClose}
disabled={isLoading}
>
{cancelLabel}
</button>
<button
type="button"
className={clsx(
'flex-1 px-4 py-2 text-white rounded-lg disabled:opacity-50',
config.buttonClass
)}
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading ? 'Procesando...' : confirmLabel}
</button>
</ModalFooter>
</div>
</Modal>
);
}

View File

@ -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<T> {
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<T> {
data: T[];
columns: DataTableColumn<T>[];
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<T>({
data,
columns,
keyField = 'id' as keyof T,
isLoading = false,
error = null,
pagination,
onPageChange,
onRowClick,
emptyState,
className,
rowClassName,
}: DataTableProps<T>) {
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 (
<div className={clsx('bg-white rounded-lg shadow-sm overflow-hidden', className)}>
{/* Loading State */}
{isLoading && <LoadingOverlay />}
{/* Error State */}
{!isLoading && error && (
<div className="p-8 text-center text-red-500">{error}</div>
)}
{/* Empty State */}
{!isLoading && !error && data.length === 0 && (
<EmptyState
title={emptyState?.title}
description={emptyState?.description}
icon={emptyState?.icon}
action={emptyState?.action}
/>
)}
{/* Table */}
{!isLoading && !error && data.length > 0 && (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{columns.map((column) => (
<th
key={column.key}
className={clsx(
'px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider',
alignClasses[column.align || 'left'],
column.className
)}
style={column.width ? { width: column.width } : undefined}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.map((item, index) => (
<tr
key={getRowKey(item, index)}
className={clsx(
'hover:bg-gray-50 transition-colors',
onRowClick && 'cursor-pointer',
getRowClassName(item, index)
)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
>
{columns.map((column) => (
<td
key={column.key}
className={clsx(
'px-6 py-4 whitespace-nowrap text-sm',
alignClasses[column.align || 'left'],
column.className
)}
>
{column.render
? column.render(item, index)
: String((item as Record<string, unknown>)[column.key] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination && totalPages > 1 && (
<div className="px-6 py-3 border-t bg-gray-50 flex items-center justify-between">
<div className="text-sm text-gray-500">
Mostrando {startItem} a {endItem} de {pagination.total} resultados
</div>
<div className="flex items-center gap-2">
<button
type="button"
className={clsx(
'p-2 rounded-lg border',
pagination.page === 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
)}
onClick={() => onPageChange?.(pagination.page - 1)}
disabled={pagination.page === 1}
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-gray-600">
Página {pagination.page} de {totalPages}
</span>
<button
type="button"
className={clsx(
'p-2 rounded-lg border',
pagination.page === totalPages
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
)}
onClick={() => onPageChange?.(pagination.page + 1)}
disabled={pagination.page === totalPages}
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@ -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 (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-gray-100 mb-4">
{icon ?? <FileX className="w-8 h-8 text-gray-400" />}
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1">{title}</h3>
<p className="text-gray-500 mb-4 max-w-sm">{description}</p>
{action}
</div>
);
}

View File

@ -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<HTMLInputElement>, BaseFieldProps {}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ label, error, required, hint, className, id, ...props }, ref) => {
const inputId = id || props.name;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<input
ref={ref}
id={inputId}
className={clsx(
'w-full px-3 py-2 border rounded-lg transition-colors',
'focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
error ? 'border-red-500' : 'border-gray-300',
className
)}
{...props}
/>
{hint && !error && (
<p className="mt-1 text-sm text-gray-500">{hint}</p>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
);
TextInput.displayName = 'TextInput';
// Select
interface SelectFieldProps extends SelectHTMLAttributes<HTMLSelectElement>, BaseFieldProps {
options: SelectOption[];
placeholder?: string;
}
export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(
({ label, error, required, hint, options, placeholder, className, id, ...props }, ref) => {
const selectId = id || props.name;
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<select
ref={ref}
id={selectId}
className={clsx(
'w-full px-3 py-2 border rounded-lg transition-colors',
'focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
error ? 'border-red-500' : 'border-gray-300',
className
)}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option
key={String(option.value)}
value={String(option.value)}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
{hint && !error && (
<p className="mt-1 text-sm text-gray-500">{hint}</p>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
);
SelectField.displayName = 'SelectField';
// Textarea
interface TextareaFieldProps extends TextareaHTMLAttributes<HTMLTextAreaElement>, BaseFieldProps {}
export const TextareaField = forwardRef<HTMLTextAreaElement, TextareaFieldProps>(
({ label, error, required, hint, className, id, rows = 3, ...props }, ref) => {
const textareaId = id || props.name;
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<textarea
ref={ref}
id={textareaId}
rows={rows}
className={clsx(
'w-full px-3 py-2 border rounded-lg transition-colors',
'focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
error ? 'border-red-500' : 'border-gray-300',
className
)}
{...props}
/>
{hint && !error && (
<p className="mt-1 text-sm text-gray-500">{hint}</p>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
);
TextareaField.displayName = 'TextareaField';
// Checkbox
interface CheckboxFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>, BaseFieldProps {
description?: string;
}
export const CheckboxField = forwardRef<HTMLInputElement, CheckboxFieldProps>(
({ label, error, description, className, id, ...props }, ref) => {
const checkboxId = id || props.name;
return (
<div className="relative flex items-start">
<div className="flex h-6 items-center">
<input
ref={ref}
id={checkboxId}
type="checkbox"
className={clsx(
'h-4 w-4 rounded border-gray-300 text-blue-600',
'focus:ring-2 focus:ring-blue-500',
className
)}
{...props}
/>
</div>
{(label || description) && (
<div className="ml-3">
{label && (
<label
htmlFor={checkboxId}
className="text-sm font-medium text-gray-700"
>
{label}
</label>
)}
{description && (
<p className="text-sm text-gray-500">{description}</p>
)}
</div>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
);
CheckboxField.displayName = 'CheckboxField';
// Form Group (for horizontal layouts)
interface FormGroupProps {
children: React.ReactNode;
cols?: 1 | 2 | 3 | 4;
className?: string;
}
export function FormGroup({ children, cols = 2, className }: FormGroupProps) {
const colClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
};
return (
<div className={clsx('grid gap-4', colClasses[cols], className)}>
{children}
</div>
);
}

View File

@ -0,0 +1,45 @@
/**
* LoadingSpinner - Reusable loading indicator component
*/
import clsx from 'clsx';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
return (
<div
className={clsx(
'animate-spin rounded-full border-2 border-gray-300 border-t-blue-600',
sizeClasses[size],
className
)}
role="status"
aria-label="Cargando"
>
<span className="sr-only">Cargando...</span>
</div>
);
}
interface LoadingOverlayProps {
message?: string;
}
export function LoadingOverlay({ message = 'Cargando...' }: LoadingOverlayProps) {
return (
<div className="flex flex-col items-center justify-center p-8">
<LoadingSpinner size="lg" />
<p className="mt-4 text-gray-500">{message}</p>
</div>
);
}

View File

@ -0,0 +1,123 @@
/**
* Modal - Reusable modal dialog component
*/
import { useEffect, useCallback, type ReactNode } from 'react';
import { X } from 'lucide-react';
import clsx from 'clsx';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
children: ReactNode;
footer?: ReactNode;
showCloseButton?: boolean;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-[90vw]',
};
export function Modal({
isOpen,
onClose,
title,
size = 'md',
children,
footer,
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
}: ModalProps) {
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (closeOnEscape && e.key === 'Escape') {
onClose();
}
},
[closeOnEscape, onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Overlay */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={closeOnOverlayClick ? onClose : undefined}
aria-hidden="true"
/>
{/* Modal container */}
<div className="flex min-h-full items-center justify-center p-4">
<div
className={clsx(
'relative w-full bg-white rounded-lg shadow-xl',
sizeClasses[size]
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b">
{title && (
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
)}
{showCloseButton && (
<button
type="button"
className="p-2 -mr-2 text-gray-400 hover:text-gray-500 hover:bg-gray-100 rounded-lg"
onClick={onClose}
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
{/* Body */}
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
{/* Footer */}
{footer && (
<div className="px-6 py-4 border-t bg-gray-50 rounded-b-lg">
{footer}
</div>
)}
</div>
</div>
</div>
);
}
interface ModalFooterProps {
children: ReactNode;
className?: string;
}
export function ModalFooter({ children, className }: ModalFooterProps) {
return (
<div className={clsx('flex justify-end gap-3', className)}>{children}</div>
);
}

View File

@ -0,0 +1,73 @@
/**
* PageHeader - Reusable page header with title, description, and actions
*/
import type { ReactNode } from 'react';
import clsx from 'clsx';
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
breadcrumbs?: ReactNode;
className?: string;
}
export function PageHeader({
title,
description,
actions,
breadcrumbs,
className,
}: PageHeaderProps) {
return (
<div className={clsx('mb-6', className)}>
{breadcrumbs && <div className="mb-2">{breadcrumbs}</div>}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
{description && <p className="text-gray-600 mt-1">{description}</p>}
</div>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
</div>
);
}
interface PageHeaderActionProps {
children: ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
className?: string;
disabled?: boolean;
type?: 'button' | 'submit';
}
export function PageHeaderAction({
children,
onClick,
variant = 'primary',
className,
disabled = false,
type = 'button',
}: PageHeaderActionProps) {
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-white text-gray-700 border hover:bg-gray-50',
};
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={clsx(
'flex items-center px-4 py-2 rounded-lg transition-colors disabled:opacity-50',
variantClasses[variant],
className
)}
>
{children}
</button>
);
}

View File

@ -0,0 +1,70 @@
/**
* SearchInput - Reusable search input with debounce
*/
import { useState, useEffect, useCallback } from 'react';
import { Search, X } from 'lucide-react';
import clsx from 'clsx';
import { SEARCH_DEBOUNCE_MS } from '../../utils';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
debounceMs?: number;
className?: string;
autoFocus?: boolean;
}
export function SearchInput({
value,
onChange,
placeholder = 'Buscar...',
debounceMs = SEARCH_DEBOUNCE_MS,
className,
autoFocus = false,
}: SearchInputProps) {
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
useEffect(() => {
const timer = setTimeout(() => {
if (localValue !== value) {
onChange(localValue);
}
}, debounceMs);
return () => clearTimeout(timer);
}, [localValue, debounceMs, onChange, value]);
const handleClear = useCallback(() => {
setLocalValue('');
onChange('');
}, [onChange]);
return (
<div className={clsx('relative', className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
autoFocus={autoFocus}
/>
{localValue && (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}

View File

@ -0,0 +1,87 @@
/**
* StatusBadge - Reusable status badge component with color variants
*/
import clsx from 'clsx';
import type { StatusColor } from '../../types';
interface StatusBadgeProps {
label: string;
color?: StatusColor;
size?: 'sm' | 'md';
showDot?: boolean;
className?: string;
}
const colorClasses: Record<StatusColor, { bg: string; text: string; dot: string }> = {
gray: { bg: 'bg-gray-100', text: 'text-gray-800', dot: 'bg-gray-500' },
green: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
yellow: { bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500' },
red: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
blue: { bg: 'bg-blue-100', text: 'text-blue-800', dot: 'bg-blue-500' },
purple: { bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500' },
orange: { bg: 'bg-orange-100', text: 'text-orange-800', dot: 'bg-orange-500' },
pink: { bg: 'bg-pink-100', text: 'text-pink-800', dot: 'bg-pink-500' },
indigo: { bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500' },
};
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-xs',
};
export function StatusBadge({
label,
color = 'gray',
size = 'md',
showDot = false,
className,
}: StatusBadgeProps) {
const colors = colorClasses[color];
return (
<span
className={clsx(
'inline-flex items-center font-medium rounded-full',
colors.bg,
colors.text,
sizeClasses[size],
className
)}
>
{showDot && (
<span className={clsx('w-1.5 h-1.5 rounded-full mr-1.5', colors.dot)} />
)}
{label}
</span>
);
}
interface StatusBadgeFromOptionsProps<T extends string> {
value: T;
options: Array<{ value: T; label: string; color?: StatusColor }>;
size?: 'sm' | 'md';
showDot?: boolean;
className?: string;
}
export function StatusBadgeFromOptions<T extends string>({
value,
options,
size,
showDot,
className,
}: StatusBadgeFromOptionsProps<T>) {
const option = options.find((o) => o.value === value);
if (!option) return null;
return (
<StatusBadge
label={option.label}
color={option.color}
size={size}
showDot={showDot}
className={className}
/>
);
}

View File

@ -0,0 +1,36 @@
/**
* Common Components Index - Central export for all common components
*/
// Loading & Empty States
export { LoadingSpinner, LoadingOverlay } from './LoadingSpinner';
export { EmptyState } from './EmptyState';
// Status & Badges
export { StatusBadge, StatusBadgeFromOptions } from './StatusBadge';
// Search & Filters
export { SearchInput } from './SearchInput';
// Modal & Dialogs
export { Modal, ModalFooter } from './Modal';
export { ConfirmDialog } from './ConfirmDialog';
// Page Layout
export { PageHeader, PageHeaderAction } from './PageHeader';
// Form Components
export {
TextInput,
SelectField,
TextareaField,
CheckboxField,
FormGroup,
} from './FormField';
// Action Components
export { ActionButton, ActionButtons, ActionMenu } from './ActionButtons';
// Data Display
export { DataTable } from './DataTable';
export type { DataTableColumn, DataTablePagination } from './DataTable';

View File

@ -1,15 +1,11 @@
/**
* IncidentesPage - HSE Incidents Management
* Refactored to use common components
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Plus,
Pencil,
Eye,
Search,
FileText,
XCircle,
AlertTriangle,
Activity,
} from 'lucide-react';
import { Plus, Pencil, Eye, FileText, XCircle, AlertTriangle, Activity } from 'lucide-react';
import {
useIncidentes,
useCreateIncidente,
@ -18,53 +14,35 @@ import {
useCloseIncidente,
} from '../../../hooks/useHSE';
import { useFraccionamientos } from '../../../hooks/useConstruccion';
import { Fraccionamiento } from '../../../services/construccion/fraccionamientos.api';
import {
import type {
Incidente,
TipoIncidente,
GravedadIncidente,
EstadoIncidente,
CreateIncidenteDto,
} from '../../../services/hse/incidentes.api';
import clsx from 'clsx';
const tipoColors: Record<TipoIncidente, string> = {
accidente: 'bg-red-100 text-red-800',
incidente: 'bg-yellow-100 text-yellow-800',
casi_accidente: 'bg-blue-100 text-blue-800',
};
const tipoLabels: Record<TipoIncidente, string> = {
accidente: 'Accidente',
incidente: 'Incidente',
casi_accidente: 'Casi Accidente',
};
const gravedadColors: Record<GravedadIncidente, string> = {
leve: 'bg-green-100 text-green-800',
moderado: 'bg-yellow-100 text-yellow-800',
grave: 'bg-orange-100 text-orange-800',
fatal: 'bg-red-100 text-red-800',
};
const gravedadLabels: Record<GravedadIncidente, string> = {
leve: 'Leve',
moderado: 'Moderado',
grave: 'Grave',
fatal: 'Fatal',
};
const estadoColors: Record<EstadoIncidente, string> = {
abierto: 'bg-yellow-100 text-yellow-800',
en_investigacion: 'bg-blue-100 text-blue-800',
cerrado: 'bg-green-100 text-green-800',
};
const estadoLabels: Record<EstadoIncidente, string> = {
abierto: 'Abierto',
en_investigacion: 'En Investigación',
cerrado: 'Cerrado',
};
Fraccionamiento,
} from '../../../types';
import {
PageHeader,
PageHeaderAction,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
Modal,
ModalFooter,
TextInput,
TextareaField,
FormGroup,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
import {
TIPO_INCIDENTE_OPTIONS,
GRAVEDAD_INCIDENTE_OPTIONS,
ESTADO_INCIDENTE_OPTIONS,
formatDate,
truncateText,
} from '../../../utils';
export function IncidentesPage() {
const [search, setSearch] = useState('');
@ -90,7 +68,6 @@ export function IncidentesPage() {
});
const { data: fraccionamientosData } = useFraccionamientos();
const createMutation = useCreateIncidente();
const updateMutation = useUpdateIncidente();
const investigateMutation = useInvestigateIncidente();
@ -107,301 +84,79 @@ export function IncidentesPage() {
};
const handleInvestigate = async (id: string, investigadorId: string) => {
await investigateMutation.mutateAsync({
id,
investigadorId,
fechaInvestigacion: new Date().toISOString(),
});
await investigateMutation.mutateAsync({ id, investigadorId, fechaInvestigacion: new Date().toISOString() });
setInvestigateModal(null);
};
const handleClose = async (id: string, observaciones?: string) => {
await closeMutation.mutateAsync({
id,
fechaCierre: new Date().toISOString(),
observaciones,
});
await closeMutation.mutateAsync({ id, fechaCierre: new Date().toISOString(), observaciones });
setCloseModal(null);
};
const incidentes = data?.items || [];
const fraccionamientos = fraccionamientosData?.items || [];
// Generate folio for display (assuming incremental numbering)
const generateFolio = (index: number) => {
const year = new Date().getFullYear();
return `INC-${year}-${String(index + 1).padStart(4, '0')}`;
};
const generateFolio = (index: number) => `INC-${new Date().getFullYear()}-${String(index + 1).padStart(4, '0')}`;
const columns: DataTableColumn<Incidente>[] = [
{ key: 'folio', header: 'Folio', render: (_, index) => <span className="font-medium text-gray-900">{generateFolio(index)}</span> },
{ key: 'fecha', header: 'Fecha/Hora', render: (item) => (
<div>
{formatDate(item.fecha)}
{item.hora && <div className="text-xs text-gray-500">{item.hora}</div>}
</div>
)},
{ key: 'tipo', header: 'Tipo', render: (item) => <StatusBadgeFromOptions value={item.tipo} options={TIPO_INCIDENTE_OPTIONS} /> },
{ key: 'gravedad', header: 'Gravedad', render: (item) => <StatusBadgeFromOptions value={item.gravedad} options={GRAVEDAD_INCIDENTE_OPTIONS} /> },
{ key: 'descripcion', header: 'Descripción', render: (item) => <span className="text-gray-500">{truncateText(item.descripcion, 50)}</span> },
{ key: 'estado', header: 'Estado', render: (item) => <StatusBadgeFromOptions value={item.estado} options={ESTADO_INCIDENTE_OPTIONS} /> },
{ key: 'actions', header: 'Acciones', align: 'right', render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link to={`/admin/hse/incidentes/${item.id}`} className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg" title="Ver detalle"><Eye className="w-4 h-4" /></Link>
<button className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg" title="Editar" onClick={(e) => { e.stopPropagation(); setEditingItem(item); setShowModal(true); }}><Pencil className="w-4 h-4" /></button>
{item.estado === 'abierto' && <button className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg" title="Investigar" onClick={(e) => { e.stopPropagation(); setInvestigateModal(item); }}><FileText className="w-4 h-4" /></button>}
{item.estado === 'en_investigacion' && <button className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg" title="Cerrar" onClick={(e) => { e.stopPropagation(); setCloseModal(item); }}><XCircle className="w-4 h-4" /></button>}
</div>
)},
];
const tipoOptions = TIPO_INCIDENTE_OPTIONS.map(o => ({ value: o.value, label: o.label }));
const gravedadOptions = GRAVEDAD_INCIDENTE_OPTIONS.map(o => ({ value: o.value, label: o.label }));
const estadoOptions = ESTADO_INCIDENTE_OPTIONS.map(o => ({ value: o.value, label: o.label }));
const fracOptions = fraccionamientos.map(f => ({ value: f.id, label: f.nombre }));
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Incidentes HSE</h1>
<p className="text-gray-600">Gestión de incidentes de seguridad, salud y medio ambiente</p>
</div>
<button
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => {
setEditingItem(null);
setShowModal(true);
}}
>
<Plus className="w-5 h-5 mr-2" />
Registrar Incidente
</button>
</div>
<PageHeader
title="Incidentes HSE"
description="Gestión de incidentes de seguridad, salud y medio ambiente"
actions={<PageHeaderAction onClick={() => { setEditingItem(null); setShowModal(true); }}><Plus className="w-5 h-5 mr-2" />Registrar Incidente</PageHeaderAction>}
/>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por folio o descripción..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={fraccionamientoFilter}
onChange={(e) => setFraccionamientoFilter(e.target.value)}
>
<option value="">Todos los fraccionamientos</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={tipoFilter}
onChange={(e) => setTipoFilter(e.target.value as TipoIncidente | '')}
>
<option value="">Todos los tipos</option>
{Object.entries(tipoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={gravedadFilter}
onChange={(e) => setGravedadFilter(e.target.value as GravedadIncidente | '')}
>
<option value="">Todas las gravedades</option>
{Object.entries(gravedadLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={estadoFilter}
onChange={(e) => setEstadoFilter(e.target.value as EstadoIncidente | '')}
>
<option value="">Todos los estados</option>
{Object.entries(estadoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
placeholder="Desde"
/>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
placeholder="Hasta"
/>
</div>
<div className="bg-white rounded-lg shadow-sm p-4 mb-6 space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<SearchInput value={search} onChange={setSearch} placeholder="Buscar por folio o descripción..." className="flex-1" />
<SelectField options={[{ value: '', label: 'Todos los fraccionamientos' }, ...fracOptions]} value={fraccionamientoFilter} onChange={(e) => setFraccionamientoFilter(e.target.value)} className="sm:w-56" />
</div>
<div className="flex flex-wrap gap-4">
<SelectField options={[{ value: '', label: 'Todos los tipos' }, ...tipoOptions]} value={tipoFilter} onChange={(e) => setTipoFilter(e.target.value as TipoIncidente | '')} className="w-40" />
<SelectField options={[{ value: '', label: 'Todas las gravedades' }, ...gravedadOptions]} value={gravedadFilter} onChange={(e) => setGravedadFilter(e.target.value as GravedadIncidente | '')} className="w-48" />
<SelectField options={[{ value: '', label: 'Todos los estados' }, ...estadoOptions]} value={estadoFilter} onChange={(e) => setEstadoFilter(e.target.value as EstadoIncidente | '')} className="w-48" />
<TextInput type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-40" />
<TextInput type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-40" />
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-500">Cargando...</div>
) : error ? (
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
) : incidentes.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No hay incidentes registrados
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Folio
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Fecha/Hora
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Tipo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Gravedad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Descripción
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{incidentes.map((item, index) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{generateFolio(index)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div>
{new Date(item.fecha).toLocaleDateString()}
{item.hora && (
<div className="text-xs text-gray-500">{item.hora}</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
tipoColors[item.tipo]
)}
>
{tipoLabels[item.tipo]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
gravedadColors[item.gravedad]
)}
>
{gravedadLabels[item.gravedad]}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{item.descripcion}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
estadoColors[item.estado]
)}
>
{estadoLabels[item.estado]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Link
to={`/admin/hse/incidentes/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={() => {
setEditingItem(item);
setShowModal(true);
}}
>
<Pencil className="w-4 h-4" />
</button>
{item.estado === 'abierto' && (
<button
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg"
title="Investigar"
onClick={() => setInvestigateModal(item)}
>
<FileText className="w-4 h-4" />
</button>
)}
{item.estado === 'en_investigacion' && (
<button
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Cerrar"
onClick={() => setCloseModal(item)}
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DataTable data={incidentes} columns={columns} isLoading={isLoading} error={error ? 'Error al cargar los datos' : null} emptyState={{ title: 'No hay incidentes', description: 'No se han registrado incidentes.' }} />
{/* Create/Edit Modal */}
{showModal && (
<IncidenteModal
item={editingItem}
fraccionamientos={fraccionamientos}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
onSubmit={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Investigate Modal */}
{investigateModal && (
<InvestigateModal
incidente={investigateModal}
onClose={() => setInvestigateModal(null)}
onSubmit={(investigadorId) => handleInvestigate(investigateModal.id, investigadorId)}
isLoading={investigateMutation.isPending}
/>
)}
{/* Close Modal */}
{closeModal && (
<CloseModal
incidente={closeModal}
onClose={() => setCloseModal(null)}
onSubmit={(observaciones) => handleClose(closeModal.id, observaciones)}
isLoading={closeMutation.isPending}
/>
)}
{showModal && <IncidenteModal item={editingItem} fraccionamientos={fraccionamientos} onClose={() => { setShowModal(false); setEditingItem(null); }} onSubmit={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending} />}
{investigateModal && <InvestigateModal onClose={() => setInvestigateModal(null)} onSubmit={(investigadorId) => handleInvestigate(investigateModal.id, investigadorId)} isLoading={investigateMutation.isPending} />}
{closeModal && <CloseModal onClose={() => setCloseModal(null)} onSubmit={(observaciones) => handleClose(closeModal.id, observaciones)} isLoading={closeMutation.isPending} />}
</div>
);
}
// Modal Component
// Incidente Modal Component
interface IncidenteModalProps {
item: Incidente | null;
fraccionamientos: Fraccionamiento[];
@ -410,13 +165,7 @@ interface IncidenteModalProps {
isLoading: boolean;
}
function IncidenteModal({
item,
fraccionamientos,
onClose,
onSubmit,
isLoading,
}: IncidenteModalProps) {
function IncidenteModal({ item, fraccionamientos, onClose, onSubmit, isLoading }: IncidenteModalProps) {
const [formData, setFormData] = useState<CreateIncidenteDto>({
fraccionamientoId: item?.fraccionamientoId || '',
tipo: item?.tipo || 'incidente',
@ -430,311 +179,74 @@ function IncidenteModal({
observaciones: item?.observaciones || '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await onSubmit(formData); };
const update = (field: keyof CreateIncidenteDto, value: string) => setFormData({ ...formData, [field]: value });
const tipoOptions = TIPO_INCIDENTE_OPTIONS.map(o => ({ value: o.value, label: o.label }));
const gravedadOptions = GRAVEDAD_INCIDENTE_OPTIONS.map(o => ({ value: o.value, label: o.label }));
const fracOptions = fraccionamientos.map(f => ({ value: f.id, label: f.nombre }));
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{item ? 'Editar Incidente' : 'Registrar Nuevo Incidente'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha *
</label>
<input
type="date"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.fecha}
onChange={(e) => setFormData({ ...formData, fecha: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hora</label>
<input
type="time"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.hora}
onChange={(e) => setFormData({ ...formData, hora: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fraccionamiento *
</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.fraccionamientoId}
onChange={(e) =>
setFormData({ ...formData, fraccionamientoId: e.target.value })
}
>
<option value="">Seleccione un fraccionamiento</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo *</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.tipo}
onChange={(e) =>
setFormData({ ...formData, tipo: e.target.value as TipoIncidente })
}
>
{Object.entries(tipoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Gravedad *
</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.gravedad}
onChange={(e) =>
setFormData({ ...formData, gravedad: e.target.value as GravedadIncidente })
}
>
{Object.entries(gravedadLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ubicación (descripción)
</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ej: Zona de excavación, Área de cimbra, etc."
value={formData.ubicacion}
onChange={(e) => setFormData({ ...formData, ubicacion: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Descripción Detallada *
</label>
<textarea
required
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Describa detalladamente lo sucedido..."
value={formData.descripcion}
onChange={(e) => setFormData({ ...formData, descripcion: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Causa Inmediata
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="¿Qué causó directamente el incidente?"
value={formData.causas}
onChange={(e) => setFormData({ ...formData, causas: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Acciones Inmediatas Tomadas
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="¿Qué acciones se tomaron de inmediato?"
value={formData.acciones}
onChange={(e) => setFormData({ ...formData, acciones: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Observaciones
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Información adicional..."
value={formData.observaciones}
onChange={(e) => setFormData({ ...formData, observaciones: e.target.value })}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Registrar'}
</button>
</div>
</form>
</div>
</div>
<Modal isOpen={true} onClose={onClose} title={item ? 'Editar Incidente' : 'Registrar Nuevo Incidente'} size="lg"
footer={<ModalFooter><button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button><button type="submit" form="incidente-form" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" disabled={isLoading}>{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Registrar'}</button></ModalFooter>}>
<form id="incidente-form" onSubmit={handleSubmit} className="space-y-4">
<FormGroup cols={2}>
<TextInput label="Fecha" type="date" required value={formData.fecha} onChange={(e) => update('fecha', e.target.value)} />
<TextInput label="Hora" type="time" value={formData.hora || ''} onChange={(e) => update('hora', e.target.value)} />
</FormGroup>
<SelectField label="Fraccionamiento" required options={[{ value: '', label: 'Seleccione un fraccionamiento' }, ...fracOptions]} value={formData.fraccionamientoId} onChange={(e) => update('fraccionamientoId', e.target.value)} />
<FormGroup cols={2}>
<SelectField label="Tipo" required options={tipoOptions} value={formData.tipo} onChange={(e) => update('tipo', e.target.value)} />
<SelectField label="Gravedad" required options={gravedadOptions} value={formData.gravedad} onChange={(e) => update('gravedad', e.target.value)} />
</FormGroup>
<TextInput label="Ubicación" placeholder="Ej: Zona de excavación, Área de cimbra, etc." value={formData.ubicacion || ''} onChange={(e) => update('ubicacion', e.target.value)} />
<TextareaField label="Descripción Detallada" required placeholder="Describa detalladamente lo sucedido..." value={formData.descripcion} onChange={(e) => update('descripcion', e.target.value)} />
<TextareaField label="Causa Inmediata" rows={2} placeholder="¿Qué causó directamente el incidente?" value={formData.causas || ''} onChange={(e) => update('causas', e.target.value)} />
<TextareaField label="Acciones Inmediatas Tomadas" rows={2} placeholder="¿Qué acciones se tomaron de inmediato?" value={formData.acciones || ''} onChange={(e) => update('acciones', e.target.value)} />
<TextareaField label="Observaciones" rows={2} placeholder="Información adicional..." value={formData.observaciones || ''} onChange={(e) => update('observaciones', e.target.value)} />
</form>
</Modal>
);
}
// Investigate Modal
interface InvestigateModalProps {
incidente: Incidente;
onClose: () => void;
onSubmit: (investigadorId: string) => Promise<void>;
isLoading: boolean;
}
interface InvestigateModalProps { onClose: () => void; onSubmit: (investigadorId: string) => Promise<void>; isLoading: boolean; }
function InvestigateModal({ onClose, onSubmit, isLoading }: InvestigateModalProps) {
const [investigadorId, setInvestigadorId] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(investigadorId);
};
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await onSubmit(investigadorId); };
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-purple-600" />
<h3 className="text-lg font-semibold">Iniciar Investigación</h3>
</div>
<p className="text-gray-600 mb-4">
Asigne un investigador responsable para iniciar el proceso de investigación del
incidente.
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Investigador Responsable *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="ID del investigador"
value={investigadorId}
onChange={(e) => setInvestigadorId(e.target.value)}
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Procesando...' : 'Iniciar Investigación'}
</button>
</div>
</form>
</div>
</div>
<Modal isOpen={true} onClose={onClose} title="Iniciar Investigación" size="sm">
<div className="flex items-center gap-3 mb-4"><AlertTriangle className="w-6 h-6 text-purple-600" /><span className="text-gray-600">Asigne un investigador responsable.</span></div>
<form onSubmit={handleSubmit}>
<TextInput label="Investigador Responsable" required placeholder="ID del investigador" value={investigadorId} onChange={(e) => setInvestigadorId(e.target.value)} className="mb-4" />
<ModalFooter>
<button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button>
<button type="submit" className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50" disabled={isLoading}>{isLoading ? 'Procesando...' : 'Iniciar Investigación'}</button>
</ModalFooter>
</form>
</Modal>
);
}
// Close Modal
interface CloseModalProps {
incidente: Incidente;
onClose: () => void;
onSubmit: (observaciones?: string) => Promise<void>;
isLoading: boolean;
}
interface CloseModalProps { onClose: () => void; onSubmit: (observaciones?: string) => Promise<void>; isLoading: boolean; }
function CloseModal({ onClose, onSubmit, isLoading }: CloseModalProps) {
const [observaciones, setObservaciones] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(observaciones || undefined);
};
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await onSubmit(observaciones || undefined); };
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<Activity className="w-6 h-6 text-green-600" />
<h3 className="text-lg font-semibold">Cerrar Incidente</h3>
</div>
<p className="text-gray-600 mb-4">
Complete la investigación y cierre el incidente. Agregue observaciones finales si es
necesario.
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Observaciones Finales
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Resumen de la investigación y conclusiones..."
value={observaciones}
onChange={(e) => setObservaciones(e.target.value)}
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Cerrando...' : 'Cerrar Incidente'}
</button>
</div>
</form>
</div>
</div>
<Modal isOpen={true} onClose={onClose} title="Cerrar Incidente" size="sm">
<div className="flex items-center gap-3 mb-4"><Activity className="w-6 h-6 text-green-600" /><span className="text-gray-600">Complete la investigación y cierre el incidente.</span></div>
<form onSubmit={handleSubmit}>
<TextareaField label="Observaciones Finales" rows={3} placeholder="Resumen de la investigación y conclusiones..." value={observaciones} onChange={(e) => setObservaciones(e.target.value)} className="mb-4" />
<ModalFooter>
<button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button>
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50" disabled={isLoading}>{isLoading ? 'Cerrando...' : 'Cerrar Incidente'}</button>
</ModalFooter>
</form>
</Modal>
);
}

View File

@ -1,14 +1,11 @@
/**
* PresupuestosPage - Management of construction budgets
* Refactored to use common components
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Plus,
Pencil,
Trash2,
Search,
Copy,
CheckCircle,
Eye,
} from 'lucide-react';
import { Plus, Pencil, Trash2, Copy, CheckCircle, Eye } from 'lucide-react';
import {
usePresupuestos,
useCreatePresupuesto,
@ -17,34 +14,30 @@ import {
useApprovePresupuesto,
useDuplicatePresupuesto,
} from '../../../hooks/usePresupuestos';
import type { Presupuesto, PresupuestoEstado, CreatePresupuestoDto } from '../../../types';
import {
Presupuesto,
PresupuestoEstado,
CreatePresupuestoDto,
} from '../../../services/presupuestos';
import clsx from 'clsx';
const estadoColors: Record<PresupuestoEstado, string> = {
borrador: 'bg-gray-100 text-gray-800',
revision: 'bg-yellow-100 text-yellow-800',
aprobado: 'bg-green-100 text-green-800',
cerrado: 'bg-blue-100 text-blue-800',
};
const estadoLabels: Record<PresupuestoEstado, string> = {
borrador: 'Borrador',
revision: 'En Revision',
aprobado: 'Aprobado',
cerrado: 'Cerrado',
};
PageHeader,
PageHeaderAction,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
Modal,
ModalFooter,
TextInput,
FormGroup,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
import { PRESUPUESTO_ESTADO_OPTIONS, formatCurrency } from '../../../utils';
export function PresupuestosPage() {
const [search, setSearch] = useState('');
const [estadoFilter, setEstadoFilter] = useState<PresupuestoEstado | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Presupuesto | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [approveConfirm, setApproveConfirm] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [approveId, setApproveId] = useState<string | null>(null);
const { data, isLoading, error } = usePresupuestos({
estado: estadoFilter || undefined,
@ -56,30 +49,23 @@ export function PresupuestosPage() {
const approveMutation = useApprovePresupuesto();
const duplicateMutation = useDuplicatePresupuesto();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleApprove = async (id: string) => {
await approveMutation.mutateAsync(id);
setApproveConfirm(null);
};
const handleDuplicate = async (id: string) => {
await duplicateMutation.mutateAsync(id);
const handleApprove = async () => {
if (approveId) {
await approveMutation.mutateAsync(approveId);
setApproveId(null);
}
};
const handleSubmit = async (formData: CreatePresupuestoDto) => {
if (editingItem) {
await updateMutation.mutateAsync({
id: editingItem.id,
data: {
codigo: formData.codigo,
nombre: formData.nombre,
estado: formData.estado,
},
});
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
@ -87,238 +73,153 @@ export function PresupuestosPage() {
setEditingItem(null);
};
const openCreate = () => { setEditingItem(null); setShowModal(true); };
const openEdit = (item: Presupuesto) => { setEditingItem(item); setShowModal(true); };
// Filter locally by search
const presupuestos = (data?.items || []).filter(
(p) =>
!search ||
(p) => !search ||
p.codigo.toLowerCase().includes(search.toLowerCase()) ||
p.nombre.toLowerCase().includes(search.toLowerCase())
);
const columns: DataTableColumn<Presupuesto>[] = [
{ key: 'codigo', header: 'Código', render: (item) => (
<span className="font-medium text-gray-900">{item.codigo}</span>
)},
{ key: 'nombre', header: 'Nombre', render: (item) => item.nombre },
{ key: 'version', header: 'Versión', align: 'center', render: (item) => (
<span className="text-gray-500">v{item.version}</span>
)},
{ key: 'estado', header: 'Estado', render: (item) => (
<StatusBadgeFromOptions value={item.estado} options={PRESUPUESTO_ESTADO_OPTIONS} />
)},
{ key: 'montoTotal', header: 'Monto Total', align: 'right', render: (item) => (
<span className="font-medium">{formatCurrency(item.montoTotal)}</span>
)},
{ key: 'actions', header: 'Acciones', align: 'right', render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/presupuestos/presupuestos/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
{item.estado === 'revision' && (
<button
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Aprobar"
onClick={(e) => { e.stopPropagation(); setApproveId(item.id); }}
>
<CheckCircle className="w-4 h-4" />
</button>
)}
<button
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg"
title="Duplicar"
onClick={(e) => { e.stopPropagation(); duplicateMutation.mutate(item.id); }}
disabled={duplicateMutation.isPending}
>
<Copy className="w-4 h-4" />
</button>
{(item.estado === 'borrador' || item.estado === 'revision') && (
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={(e) => { e.stopPropagation(); openEdit(item); }}
>
<Pencil className="w-4 h-4" />
</button>
)}
{item.estado === 'borrador' && (
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={(e) => { e.stopPropagation(); setDeleteId(item.id); }}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)},
];
const filterOptions = PRESUPUESTO_ESTADO_OPTIONS.map(o => ({ value: o.value, label: o.label }));
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Presupuestos</h1>
<p className="text-gray-600">Gestion de presupuestos de obra</p>
</div>
<button
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => {
setEditingItem(null);
setShowModal(true);
}}
>
<Plus className="w-5 h-5 mr-2" />
Nuevo Presupuesto
</button>
</div>
<PageHeader
title="Presupuestos"
description="Gestión de presupuestos de obra"
actions={
<PageHeaderAction onClick={openCreate}>
<Plus className="w-5 h-5 mr-2" />
Nuevo Presupuesto
</PageHeaderAction>
}
/>
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por codigo o nombre..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por código o nombre..."
className="flex-1"
/>
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...filterOptions]}
value={estadoFilter}
onChange={(e) => setEstadoFilter(e.target.value as PresupuestoEstado | '')}
>
<option value="">Todos los estados</option>
{Object.entries(estadoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
className="sm:w-48"
/>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-500">Cargando...</div>
) : error ? (
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
) : presupuestos.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay presupuestos</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Codigo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Nombre
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">
Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Monto Total
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{presupuestos.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.codigo}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.nombre}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
v{item.version}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
estadoColors[item.estado]
)}
>
{estadoLabels[item.estado]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
${item.montoTotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/presupuestos/presupuestos/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
{item.estado === 'revision' && (
<button
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Aprobar"
onClick={() => setApproveConfirm(item.id)}
>
<CheckCircle className="w-4 h-4" />
</button>
)}
<button
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg"
title="Duplicar"
onClick={() => handleDuplicate(item.id)}
disabled={duplicateMutation.isPending}
>
<Copy className="w-4 h-4" />
</button>
{(item.estado === 'borrador' || item.estado === 'revision') && (
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={() => {
setEditingItem(item);
setShowModal(true);
}}
>
<Pencil className="w-4 h-4" />
</button>
)}
{item.estado === 'borrador' && (
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={() => setDeleteConfirm(item.id)}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DataTable
data={presupuestos}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{ title: 'No hay presupuestos', description: 'Crea el primer presupuesto para comenzar.' }}
/>
{showModal && (
<PresupuestoModal
item={editingItem}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
onClose={() => { setShowModal(false); setEditingItem(null); }}
onSubmit={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
<p className="text-gray-600 mb-6">
¿Esta seguro de eliminar este presupuesto? Esta accion no se puede deshacer.
</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={() => setDeleteConfirm(null)}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
onClick={() => handleDelete(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Confirmar eliminación"
message="¿Está seguro de eliminar este presupuesto? Esta acción no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
{approveConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Confirmar aprobacion</h3>
<p className="text-gray-600 mb-6">
¿Esta seguro de aprobar este presupuesto? Una vez aprobado, no podra ser editado.
</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={() => setApproveConfirm(null)}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
onClick={() => handleApprove(approveConfirm)}
disabled={approveMutation.isPending}
>
{approveMutation.isPending ? 'Aprobando...' : 'Aprobar'}
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={!!approveId}
onClose={() => setApproveId(null)}
onConfirm={handleApprove}
title="Confirmar aprobación"
message="¿Está seguro de aprobar este presupuesto? Una vez aprobado, no podrá ser editado."
confirmLabel="Aprobar"
variant="info"
isLoading={approveMutation.isPending}
/>
</div>
);
}
// Modal Component
interface PresupuestoModalProps {
item: Presupuesto | null;
onClose: () => void;
@ -339,73 +240,58 @@ function PresupuestoModal({ item, onClose, onSubmit, isLoading }: PresupuestoMod
await onSubmit(formData);
};
const editableStates: PresupuestoEstado[] = ['borrador', 'revision'];
const update = (field: keyof CreatePresupuestoDto, value: string) => {
setFormData({ ...formData, [field]: value });
};
const editableStates = PRESUPUESTO_ESTADO_OPTIONS
.filter(o => o.value === 'borrador' || o.value === 'revision')
.map(o => ({ value: o.value, label: o.label }));
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{item ? 'Editar Presupuesto' : 'Nuevo Presupuesto'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.codigo}
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.estado}
onChange={(e) =>
setFormData({ ...formData, estado: e.target.value as PresupuestoEstado })
}
>
{editableStates.map((estado) => (
<option key={estado} value={estado}>
{estadoLabels[estado]}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
</button>
</div>
</form>
</div>
</div>
<Modal
isOpen={true}
onClose={onClose}
title={item ? 'Editar Presupuesto' : 'Nuevo Presupuesto'}
size="md"
footer={
<ModalFooter>
<button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>
Cancelar
</button>
<button
type="submit"
form="presupuesto-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
</button>
</ModalFooter>
}
>
<form id="presupuesto-form" onSubmit={handleSubmit} className="space-y-4">
<FormGroup cols={2}>
<TextInput
label="Código"
required
value={formData.codigo}
onChange={(e) => update('codigo', e.target.value)}
/>
<SelectField
label="Estado"
options={editableStates}
value={formData.estado || 'borrador'}
onChange={(e) => update('estado', e.target.value)}
/>
</FormGroup>
<TextInput
label="Nombre"
required
value={formData.nombre}
onChange={(e) => update('nombre', e.target.value)}
/>
</form>
</Modal>
);
}

View File

@ -1,39 +1,45 @@
/**
* FraccionamientosPage - Management of fraccionamientos (developments)
* Refactored to use common components
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react';
import { Plus, Eye, Pencil, Trash2 } from 'lucide-react';
import {
useFraccionamientos,
useDeleteFraccionamiento,
useCreateFraccionamiento,
useUpdateFraccionamiento,
} from '../../../hooks/useConstruccion';
import {
import type {
Fraccionamiento,
FraccionamientoEstado,
CreateFraccionamientoDto,
} from '../../../services/construccion/fraccionamientos.api';
import clsx from 'clsx';
const estadoColors: Record<FraccionamientoEstado, string> = {
activo: 'bg-green-100 text-green-800',
pausado: 'bg-yellow-100 text-yellow-800',
completado: 'bg-blue-100 text-blue-800',
cancelado: 'bg-red-100 text-red-800',
};
const estadoLabels: Record<FraccionamientoEstado, string> = {
activo: 'Activo',
pausado: 'Pausado',
completado: 'Completado',
cancelado: 'Cancelado',
};
} from '../../../types';
import {
PageHeader,
PageHeaderAction,
DataTable,
SearchInput,
StatusBadgeFromOptions,
ConfirmDialog,
Modal,
ModalFooter,
TextInput,
SelectField,
TextareaField,
FormGroup,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
import { FRACCIONAMIENTO_ESTADO_OPTIONS, formatDate } from '../../../utils';
export function FraccionamientosPage() {
const [search, setSearch] = useState('');
const [estadoFilter, setEstadoFilter] = useState<FraccionamientoEstado | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Fraccionamiento | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const { data, isLoading, error } = useFraccionamientos({
search: search || undefined,
@ -44,9 +50,11 @@ export function FraccionamientosPage() {
const createMutation = useCreateFraccionamiento();
const updateMutation = useUpdateFraccionamiento();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleSubmit = async (formData: CreateFraccionamientoDto) => {
@ -59,182 +67,111 @@ export function FraccionamientosPage() {
setEditingItem(null);
};
const fraccionamientos = data?.items || [];
const openCreate = () => {
setEditingItem(null);
setShowModal(true);
};
const openEdit = (item: Fraccionamiento) => {
setEditingItem(item);
setShowModal(true);
};
const columns: DataTableColumn<Fraccionamiento>[] = [
{ key: 'codigo', header: 'Código', render: (item) => (
<span className="font-medium text-gray-900">{item.codigo}</span>
)},
{ key: 'nombre', header: 'Nombre', render: (item) => item.nombre },
{ key: 'estado', header: 'Estado', render: (item) => (
<StatusBadgeFromOptions value={item.estado} options={FRACCIONAMIENTO_ESTADO_OPTIONS} />
)},
{ key: 'fechaInicio', header: 'Fecha Inicio', render: (item) => formatDate(item.fechaInicio) },
{ key: 'actions', header: 'Acciones', align: 'right', render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/proyectos/fraccionamientos/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={(e) => { e.stopPropagation(); openEdit(item); }}
>
<Pencil className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={(e) => { e.stopPropagation(); setDeleteId(item.id); }}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)},
];
const filterOptions = FRACCIONAMIENTO_ESTADO_OPTIONS.map(o => ({ value: o.value, label: o.label }));
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Fraccionamientos</h1>
<p className="text-gray-600">Gestion de fraccionamientos y desarrollos</p>
</div>
<button
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => {
setEditingItem(null);
setShowModal(true);
}}
>
<Plus className="w-5 h-5 mr-2" />
Nuevo Fraccionamiento
</button>
</div>
<PageHeader
title="Fraccionamientos"
description="Gestión de fraccionamientos y desarrollos"
actions={
<PageHeaderAction onClick={openCreate}>
<Plus className="w-5 h-5 mr-2" />
Nuevo Fraccionamiento
</PageHeaderAction>
}
/>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por nombre o codigo..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por nombre o código..."
className="flex-1"
/>
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...filterOptions]}
value={estadoFilter}
onChange={(e) => setEstadoFilter(e.target.value as FraccionamientoEstado | '')}
>
<option value="">Todos los estados</option>
{Object.entries(estadoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
className="sm:w-48"
/>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-500">Cargando...</div>
) : error ? (
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
) : fraccionamientos.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay fraccionamientos</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Codigo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Nombre
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Estado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Fecha Inicio
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{fraccionamientos.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.codigo}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.nombre}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
estadoColors[item.estado]
)}
>
{estadoLabels[item.estado]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.fechaInicio
? new Date(item.fechaInicio).toLocaleDateString()
: '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Link
to={`/admin/proyectos/fraccionamientos/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={() => {
setEditingItem(item);
setShowModal(true);
}}
>
<Pencil className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={() => setDeleteConfirm(item.id)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DataTable
data={data?.items || []}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{ title: 'No hay fraccionamientos', description: 'Crea el primer fraccionamiento para comenzar.' }}
/>
{/* Modal */}
{showModal && (
<FraccionamientoModal
item={editingItem}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
onClose={() => { setShowModal(false); setEditingItem(null); }}
onSubmit={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Delete Confirmation */}
{deleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
<p className="text-gray-600 mb-6">
¿Esta seguro de eliminar este fraccionamiento? Esta accion no se puede deshacer.
</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={() => setDeleteConfirm(null)}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
onClick={() => handleDelete(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Confirmar eliminación"
message="¿Está seguro de eliminar este fraccionamiento? Esta acción no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
</div>
);
}
@ -264,121 +201,84 @@ function FraccionamientoModal({ item, onClose, onSubmit, isLoading }: Fraccionam
await onSubmit(formData);
};
const update = (field: keyof CreateFraccionamientoDto, value: string) => {
setFormData({ ...formData, [field]: value });
};
const estadoOptions = FRACCIONAMIENTO_ESTADO_OPTIONS.map(o => ({ value: o.value, label: o.label }));
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{item ? 'Editar Fraccionamiento' : 'Nuevo Fraccionamiento'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Codigo *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.codigo}
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.estado}
onChange={(e) =>
setFormData({ ...formData, estado: e.target.value as FraccionamientoEstado })
}
>
{Object.entries(estadoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Descripcion
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.descripcion}
onChange={(e) => setFormData({ ...formData, descripcion: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Direccion
</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.direccion}
onChange={(e) => setFormData({ ...formData, direccion: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Inicio
</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.fechaInicio}
onChange={(e) => setFormData({ ...formData, fechaInicio: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Fin Estimada
</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.fechaFinEstimada}
onChange={(e) => setFormData({ ...formData, fechaFinEstimada: e.target.value })}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
</button>
</div>
</form>
</div>
</div>
<Modal
isOpen={true}
onClose={onClose}
title={item ? 'Editar Fraccionamiento' : 'Nuevo Fraccionamiento'}
size="lg"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="fraccionamiento-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
</button>
</ModalFooter>
}
>
<form id="fraccionamiento-form" onSubmit={handleSubmit} className="space-y-4">
<FormGroup cols={2}>
<TextInput
label="Código"
required
value={formData.codigo}
onChange={(e) => update('codigo', e.target.value)}
/>
<SelectField
label="Estado"
options={estadoOptions}
value={formData.estado || 'activo'}
onChange={(e) => update('estado', e.target.value)}
/>
</FormGroup>
<TextInput
label="Nombre"
required
value={formData.nombre}
onChange={(e) => update('nombre', e.target.value)}
/>
<TextareaField
label="Descripción"
value={formData.descripcion || ''}
onChange={(e) => update('descripcion', e.target.value)}
/>
<TextInput
label="Dirección"
value={formData.direccion || ''}
onChange={(e) => update('direccion', e.target.value)}
/>
<FormGroup cols={2}>
<TextInput
label="Fecha Inicio"
type="date"
value={formData.fechaInicio || ''}
onChange={(e) => update('fechaInicio', e.target.value)}
/>
<TextInput
label="Fecha Fin Estimada"
type="date"
value={formData.fechaFinEstimada || ''}
onChange={(e) => update('fechaFinEstimada', e.target.value)}
/>
</FormGroup>
</form>
</Modal>
);
}

View File

@ -0,0 +1,50 @@
/**
* API Types - Common types for API responses and requests
*/
export interface ApiError {
message: string;
statusCode: number;
error?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page?: number;
limit?: number;
}
export interface PaginationParams {
page?: number;
limit?: number;
search?: string;
}
export interface PaginationState {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface SortParams {
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface DateRangeParams {
dateFrom?: string;
dateTo?: string;
}
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}
export interface BlobResponse {
blob: Blob;
filename?: string;
}

View File

@ -0,0 +1,56 @@
/**
* Auth Types - Authentication and user-related types
*/
export interface User {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
tenantId: string;
status: string;
role?: string;
}
export interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface LoginResponse {
user: User;
accessToken: string;
refreshToken: string;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
export interface RefreshTokenResponse {
accessToken: string;
refreshToken: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface ResetPasswordRequest {
email: string;
}
export interface RegisterRequest {
email: string;
password: string;
firstName?: string;
lastName?: string;
}

View File

@ -0,0 +1,163 @@
/**
* Common Types - Shared interfaces and enums used across the application
*/
// ===========================
// STATUS TYPES
// ===========================
export type GenericStatus = 'active' | 'inactive' | 'pending' | 'archived';
export interface StatusOption<T = string> {
value: T;
label: string;
color?: StatusColor;
icon?: string;
}
export type StatusColor =
| 'gray'
| 'green'
| 'yellow'
| 'red'
| 'blue'
| 'purple'
| 'orange'
| 'pink'
| 'indigo';
// ===========================
// UI COMPONENT TYPES
// ===========================
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type Variant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info';
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
}
export interface TableColumn<T> {
key: keyof T | string;
header: string;
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
render?: (item: T) => React.ReactNode;
}
export interface TableAction<T> {
label: string;
icon?: React.ReactNode;
onClick: (item: T) => void;
show?: (item: T) => boolean;
variant?: Variant;
}
// ===========================
// FORM TYPES
// ===========================
export interface FormField {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'password' | 'textarea' | 'select' | 'date' | 'checkbox';
placeholder?: string;
required?: boolean;
disabled?: boolean;
options?: SelectOption[];
validation?: Record<string, unknown>;
}
export interface FormErrors {
[key: string]: string | undefined;
}
// ===========================
// NAVIGATION TYPES
// ===========================
export interface BreadcrumbItem {
label: string;
href?: string;
current?: boolean;
}
export interface MenuItem {
label: string;
href: string;
icon?: React.ReactNode;
badge?: string | number;
children?: MenuItem[];
}
// ===========================
// FILTER TYPES
// ===========================
export interface FilterOption<T = string> {
key: string;
label: string;
type: 'text' | 'select' | 'date' | 'dateRange' | 'number' | 'boolean';
options?: SelectOption<T>[];
placeholder?: string;
}
export interface ActiveFilter {
key: string;
value: unknown;
label: string;
}
// ===========================
// MODAL TYPES
// ===========================
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
}
export interface ConfirmDialogProps extends ModalProps {
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'warning' | 'info';
onConfirm: () => void;
isLoading?: boolean;
}
// ===========================
// ENTITY BASE TYPES
// ===========================
export interface BaseEntity {
id: string;
tenantId: string;
createdAt: string;
updatedAt: string;
}
export interface AuditableEntity extends BaseEntity {
createdBy?: string;
updatedBy?: string;
deletedAt?: string;
deletedBy?: string;
}
// ===========================
// UTILITY TYPES
// ===========================
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
export type Nullable<T> = T | null;
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

View File

@ -0,0 +1,276 @@
/**
* Construction Types - Types for construction management (projects, fraccoinamientos, etapas, lotes, etc.)
*/
import type { PaginationParams } from './api.types';
// ===========================
// FRACCIONAMIENTOS
// ===========================
export type FraccionamientoEstado = 'activo' | 'pausado' | 'completado' | 'cancelado';
export interface Fraccionamiento {
id: string;
tenantId: string;
proyectoId: string;
codigo: string;
nombre: string;
descripcion?: string;
direccion?: string;
fechaInicio?: string;
fechaFinEstimada?: string;
estado: FraccionamientoEstado;
createdAt: string;
updatedAt: string;
}
export interface FraccionamientoFilters extends PaginationParams {
proyectoId?: string;
estado?: FraccionamientoEstado;
}
export interface CreateFraccionamientoDto {
codigo: string;
nombre: string;
proyectoId: string;
descripcion?: string;
direccion?: string;
fechaInicio?: string;
fechaFinEstimada?: string;
estado?: FraccionamientoEstado;
}
export interface UpdateFraccionamientoDto {
codigo?: string;
nombre?: string;
descripcion?: string;
direccion?: string;
fechaInicio?: string;
fechaFinEstimada?: string;
estado?: FraccionamientoEstado;
}
// ===========================
// ETAPAS
// ===========================
export type EtapaStatus = 'planned' | 'in_progress' | 'completed' | 'cancelled';
export interface Etapa {
id: string;
tenantId: string;
fraccionamientoId: string;
code: string;
name: string;
description?: string;
sequence: number;
totalLots: number;
status: EtapaStatus;
startDate?: string;
expectedEndDate?: string;
actualEndDate?: string;
createdAt: string;
updatedAt: string;
fraccionamiento?: {
id: string;
nombre: string;
codigo: string;
};
}
export interface EtapaFilters extends PaginationParams {
fraccionamientoId?: string;
status?: EtapaStatus;
}
export interface CreateEtapaDto {
code: string;
name: string;
fraccionamientoId: string;
description?: string;
sequence?: number;
totalLots?: number;
status?: EtapaStatus;
startDate?: string;
expectedEndDate?: string;
}
export interface UpdateEtapaDto {
code?: string;
name?: string;
description?: string;
sequence?: number;
totalLots?: number;
status?: EtapaStatus;
startDate?: string;
expectedEndDate?: string;
actualEndDate?: string;
}
// ===========================
// MANZANAS
// ===========================
export interface Manzana {
id: string;
tenantId: string;
etapaId: string;
code: string;
name: string;
totalLots?: number;
createdAt: string;
updatedAt: string;
etapa?: {
id: string;
code: string;
name: string;
fraccionamientoId: string;
};
}
export interface ManzanaFilters extends PaginationParams {
etapaId?: string;
}
export interface CreateManzanaDto {
code: string;
name: string;
etapaId: string;
totalLots?: number;
}
export interface UpdateManzanaDto {
code?: string;
name?: string;
totalLots?: number;
}
// ===========================
// LOTES
// ===========================
export type LoteStatus = 'available' | 'reserved' | 'sold' | 'blocked' | 'in_construction';
export interface Lote {
id: string;
tenantId: string;
manzanaId: string;
prototipoId?: string;
code: string;
officialNumber?: string;
areaM2: number;
frontM: number;
depthM: number;
status: LoteStatus;
basePrice?: number;
finalPrice?: number;
notes?: string;
createdAt: string;
updatedAt: string;
manzana?: {
id: string;
code: string;
name: string;
etapaId: string;
};
prototipo?: {
id: string;
code: string;
name: string;
type: string;
};
}
export interface LoteFilters extends PaginationParams {
manzanaId?: string;
prototipoId?: string;
status?: LoteStatus;
}
export interface CreateLoteDto {
code: string;
manzanaId: string;
officialNumber?: string;
areaM2: number;
frontM: number;
depthM: number;
status?: LoteStatus;
basePrice?: number;
finalPrice?: number;
prototipoId?: string;
notes?: string;
}
export interface UpdateLoteDto {
code?: string;
officialNumber?: string;
areaM2?: number;
frontM?: number;
depthM?: number;
basePrice?: number;
finalPrice?: number;
notes?: string;
}
export interface LoteStats {
total: number;
available: number;
reserved: number;
sold: number;
blocked: number;
inConstruction: number;
}
// ===========================
// PROTOTIPOS
// ===========================
export type PrototipoType = 'house' | 'apartment' | 'commercial' | 'land';
export interface Prototipo {
id: string;
tenantId: string;
code: string;
name: string;
type: PrototipoType;
description?: string;
constructionArea?: number;
totalArea?: number;
bedrooms?: number;
bathrooms?: number;
parking?: number;
basePrice?: number;
createdAt: string;
updatedAt: string;
}
export interface PrototipoFilters extends PaginationParams {
type?: PrototipoType;
}
export interface CreatePrototipoDto {
code: string;
name: string;
type: PrototipoType;
description?: string;
constructionArea?: number;
totalArea?: number;
bedrooms?: number;
bathrooms?: number;
parking?: number;
basePrice?: number;
}
export interface UpdatePrototipoDto {
code?: string;
name?: string;
type?: PrototipoType;
description?: string;
constructionArea?: number;
totalArea?: number;
bedrooms?: number;
bathrooms?: number;
parking?: number;
basePrice?: number;
}

View File

@ -0,0 +1,259 @@
/**
* Estimates Types - Types for budgets, estimates, concepts, and generators
*/
import type { PaginationParams } from './api.types';
// ===========================
// CONCEPTOS
// ===========================
export type ConceptoTipo = 'capitulo' | 'partida' | 'subpartida' | 'concepto';
export interface Concepto {
id: string;
tenantId: string;
parentId?: string;
codigo: string;
descripcion: string;
unidad?: string;
precioUnitario?: number;
tipo: ConceptoTipo;
nivel: number;
ruta?: string;
children?: Concepto[];
createdAt: string;
updatedAt: string;
}
export interface ConceptoFilters extends PaginationParams {
parentId?: string;
tipo?: ConceptoTipo;
}
export interface CreateConceptoDto {
codigo: string;
descripcion: string;
tipo: ConceptoTipo;
parentId?: string;
unidad?: string;
precioUnitario?: number;
}
export interface UpdateConceptoDto {
codigo?: string;
descripcion?: string;
tipo?: ConceptoTipo;
parentId?: string;
unidad?: string;
precioUnitario?: number;
}
// ===========================
// PRESUPUESTOS
// ===========================
export type PresupuestoEstado = 'borrador' | 'revision' | 'aprobado' | 'cerrado';
export interface Presupuesto {
id: string;
tenantId: string;
proyectoId: string;
codigo: string;
nombre: string;
version: number;
estado: PresupuestoEstado;
montoTotal: number;
fechaCreacion: string;
fechaAprobacion?: string;
partidas?: PresupuestoPartida[];
createdAt: string;
updatedAt: string;
}
export interface PresupuestoPartida {
id: string;
presupuestoId: string;
conceptoId: string;
concepto?: Concepto;
cantidad: number;
precioUnitario: number;
importe: number;
orden: number;
}
export interface PresupuestoFilters extends PaginationParams {
proyectoId?: string;
estado?: PresupuestoEstado;
}
export interface CreatePresupuestoDto {
codigo: string;
nombre: string;
proyectoId: string;
estado?: PresupuestoEstado;
}
export interface UpdatePresupuestoDto {
codigo?: string;
nombre?: string;
estado?: PresupuestoEstado;
}
export interface CreatePresupuestoPartidaDto {
conceptoId: string;
cantidad: number;
precioUnitario: number;
orden?: number;
}
export interface UpdatePresupuestoPartidaDto {
cantidad?: number;
precioUnitario?: number;
orden?: number;
}
export interface PresupuestoVersion {
id: string;
presupuestoId: string;
version: number;
estado: PresupuestoEstado;
montoTotal: number;
fechaCreacion: string;
creadoPor?: string;
notas?: string;
}
export interface RejectPresupuestoDto {
motivo: string;
}
// ===========================
// ESTIMACIONES
// ===========================
export type EstimacionEstado =
| 'borrador'
| 'revision'
| 'aprobado'
| 'facturado'
| 'cobrado'
| 'rechazado';
export interface Estimacion {
id: string;
tenantId: string;
presupuestoId: string;
proyectoId: string;
numero: number;
periodo: string;
fechaInicio: string;
fechaFin: string;
estado: EstimacionEstado;
montoEstimado: number;
montoAprobado?: number;
montoFacturado?: number;
montoCobrado?: number;
anticipoPorcentaje?: number;
retencionPorcentaje?: number;
deductivas?: number;
observaciones?: string;
fechaAprobacion?: string;
fechaFacturacion?: string;
fechaCobro?: string;
partidas?: EstimacionPartida[];
createdAt: string;
updatedAt: string;
}
export interface EstimacionPartida {
id: string;
estimacionId: string;
conceptoId: string;
concepto?: Concepto;
cantidadEstimada: number;
cantidadAprobada?: number;
precioUnitario: number;
importeEstimado: number;
importeAprobado?: number;
observaciones?: string;
generadores?: Generador[];
}
export interface Generador {
id: string;
partidaId: string;
descripcion: string;
croquis?: string;
largo?: number;
ancho?: number;
alto?: number;
cantidad: number;
parcial: number;
ubicacion?: string;
fechaEjecucion?: string;
}
export interface EstimacionFilters extends PaginationParams {
presupuestoId?: string;
proyectoId?: string;
estado?: EstimacionEstado;
periodo?: string;
}
export interface CreateEstimacionDto {
presupuestoId: string;
proyectoId: string;
periodo: string;
fechaInicio: string;
fechaFin: string;
anticipoPorcentaje?: number;
retencionPorcentaje?: number;
observaciones?: string;
}
export interface UpdateEstimacionDto {
periodo?: string;
fechaInicio?: string;
fechaFin?: string;
anticipoPorcentaje?: number;
retencionPorcentaje?: number;
deductivas?: number;
observaciones?: string;
}
export interface CreateEstimacionPartidaDto {
conceptoId: string;
cantidadEstimada: number;
precioUnitario: number;
observaciones?: string;
}
export interface UpdateEstimacionPartidaDto {
cantidadEstimada?: number;
cantidadAprobada?: number;
precioUnitario?: number;
observaciones?: string;
}
export interface CreateGeneradorDto {
descripcion: string;
croquis?: string;
largo?: number;
ancho?: number;
alto?: number;
cantidad: number;
ubicacion?: string;
fechaEjecucion?: string;
}
export interface UpdateGeneradorDto {
descripcion?: string;
croquis?: string;
largo?: number;
ancho?: number;
alto?: number;
cantidad?: number;
ubicacion?: string;
fechaEjecucion?: string;
}

213
web/src/types/hse.types.ts Normal file
View File

@ -0,0 +1,213 @@
/**
* HSE Types - Health, Safety, and Environment types
*/
import type { PaginationParams, DateRangeParams } from './api.types';
// ===========================
// INCIDENTES
// ===========================
export type TipoIncidente = 'accidente' | 'incidente' | 'casi_accidente';
export type GravedadIncidente = 'leve' | 'moderado' | 'grave' | 'fatal';
export type EstadoIncidente = 'abierto' | 'en_investigacion' | 'cerrado';
export interface Incidente {
id: string;
tenantId: string;
fraccionamientoId: string;
tipo: TipoIncidente;
gravedad: GravedadIncidente;
estado: EstadoIncidente;
fecha: string;
hora?: string;
ubicacion?: string;
descripcion: string;
causas?: string;
acciones?: string;
responsableId?: string;
investigadorId?: string;
fechaInvestigacion?: string;
fechaCierre?: string;
observaciones?: string;
createdAt: string;
updatedAt: string;
}
export interface IncidenteFilters extends PaginationParams, DateRangeParams {
fraccionamientoId?: string;
tipo?: TipoIncidente;
gravedad?: GravedadIncidente;
estado?: EstadoIncidente;
}
export interface CreateIncidenteDto {
fraccionamientoId: string;
tipo: TipoIncidente;
gravedad: GravedadIncidente;
fecha: string;
hora?: string;
ubicacion?: string;
descripcion: string;
causas?: string;
acciones?: string;
responsableId?: string;
observaciones?: string;
}
export interface UpdateIncidenteDto {
tipo?: TipoIncidente;
gravedad?: GravedadIncidente;
estado?: EstadoIncidente;
fecha?: string;
hora?: string;
ubicacion?: string;
descripcion?: string;
causas?: string;
acciones?: string;
responsableId?: string;
investigadorId?: string;
fechaInvestigacion?: string;
fechaCierre?: string;
observaciones?: string;
}
export interface IncidenteStats {
total: number;
porTipo: Record<TipoIncidente, number>;
porGravedad: Record<GravedadIncidente, number>;
porEstado: Record<EstadoIncidente, number>;
abiertos: number;
cerrados: number;
}
// ===========================
// CAPACITACIONES
// ===========================
export type TipoCapacitacion = 'induccion' | 'especifica' | 'certificacion' | 'reentrenamiento';
export interface Capacitacion {
id: string;
tenantId: string;
codigo: string;
nombre: string;
descripcion?: string;
tipo: TipoCapacitacion;
duracionHoras: number;
temario?: string;
objetivos?: string;
requisitos?: string;
activo: boolean;
createdAt: string;
updatedAt: string;
}
export interface CapacitacionFilters extends PaginationParams {
tipo?: TipoCapacitacion;
activo?: boolean;
}
export interface CreateCapacitacionDto {
codigo: string;
nombre: string;
descripcion?: string;
tipo: TipoCapacitacion;
duracionHoras: number;
temario?: string;
objetivos?: string;
requisitos?: string;
activo?: boolean;
}
export interface UpdateCapacitacionDto {
codigo?: string;
nombre?: string;
descripcion?: string;
tipo?: TipoCapacitacion;
duracionHoras?: number;
temario?: string;
objetivos?: string;
requisitos?: string;
activo?: boolean;
}
// ===========================
// INSPECCIONES
// ===========================
export type GravedadHallazgo = 'baja' | 'media' | 'alta' | 'critica';
export interface TipoInspeccion {
id: string;
tenantId: string;
codigo: string;
nombre: string;
descripcion?: string;
activo: boolean;
createdAt: string;
updatedAt: string;
}
export interface Inspeccion {
id: string;
tenantId: string;
tipoInspeccionId: string;
fraccionamientoId: string;
fecha: string;
hora?: string;
responsableId: string;
estado: string;
observaciones?: string;
hallazgos?: Hallazgo[];
createdAt: string;
updatedAt: string;
}
export interface Hallazgo {
id: string;
inspeccionId: string;
item: string;
descripcion: string;
gravedad: GravedadHallazgo;
accionCorrectiva?: string;
responsableId?: string;
fechaCompromiso?: string;
fechaCierre?: string;
cerrado: boolean;
evidencia?: string;
createdAt: string;
updatedAt: string;
}
export interface InspeccionFilters extends PaginationParams, DateRangeParams {
tipoInspeccionId?: string;
fraccionamientoId?: string;
estado?: string;
}
export interface CreateInspeccionDto {
tipoInspeccionId: string;
fraccionamientoId: string;
fecha: string;
hora?: string;
responsableId: string;
observaciones?: string;
}
export interface CreateHallazgoDto {
item: string;
descripcion: string;
gravedad: GravedadHallazgo;
accionCorrectiva?: string;
responsableId?: string;
fechaCompromiso?: string;
}
export interface InspeccionStats {
total: number;
porEstado: Record<string, number>;
hallazgosPorGravedad: Record<GravedadHallazgo, number>;
hallazgosAbiertos: number;
hallazgosCerrados: number;
}

137
web/src/types/index.ts Normal file
View File

@ -0,0 +1,137 @@
/**
* Types Index - Central export for all application types
*/
// API Types
export type {
ApiError,
PaginatedResponse,
PaginationParams,
PaginationState,
SortParams,
DateRangeParams,
ApiResponse,
BlobResponse,
} from './api.types';
// Auth Types
export type {
User,
AuthState,
LoginCredentials,
LoginResponse,
RefreshTokenRequest,
RefreshTokenResponse,
ChangePasswordRequest,
ResetPasswordRequest,
RegisterRequest,
} from './auth.types';
// Construction Types
export type {
FraccionamientoEstado,
Fraccionamiento,
FraccionamientoFilters,
CreateFraccionamientoDto,
UpdateFraccionamientoDto,
EtapaStatus,
Etapa,
EtapaFilters,
CreateEtapaDto,
UpdateEtapaDto,
Manzana,
ManzanaFilters,
CreateManzanaDto,
UpdateManzanaDto,
LoteStatus,
Lote,
LoteFilters,
CreateLoteDto,
UpdateLoteDto,
LoteStats,
PrototipoType,
Prototipo,
PrototipoFilters,
CreatePrototipoDto,
UpdatePrototipoDto,
} from './construction.types';
// Estimates Types
export type {
ConceptoTipo,
Concepto,
ConceptoFilters,
CreateConceptoDto,
UpdateConceptoDto,
PresupuestoEstado,
Presupuesto,
PresupuestoPartida,
PresupuestoFilters,
CreatePresupuestoDto,
UpdatePresupuestoDto,
CreatePresupuestoPartidaDto,
UpdatePresupuestoPartidaDto,
PresupuestoVersion,
RejectPresupuestoDto,
EstimacionEstado,
Estimacion,
EstimacionPartida,
Generador,
EstimacionFilters,
CreateEstimacionDto,
UpdateEstimacionDto,
CreateEstimacionPartidaDto,
UpdateEstimacionPartidaDto,
CreateGeneradorDto,
UpdateGeneradorDto,
} from './estimates.types';
// HSE Types
export type {
TipoIncidente,
GravedadIncidente,
EstadoIncidente,
Incidente,
IncidenteFilters,
CreateIncidenteDto,
UpdateIncidenteDto,
IncidenteStats,
TipoCapacitacion,
Capacitacion,
CapacitacionFilters,
CreateCapacitacionDto,
UpdateCapacitacionDto,
GravedadHallazgo,
TipoInspeccion,
Inspeccion,
Hallazgo,
InspeccionFilters,
CreateInspeccionDto,
CreateHallazgoDto,
InspeccionStats,
} from './hse.types';
// Common Types
export type {
GenericStatus,
StatusOption,
StatusColor,
Size,
Variant,
SelectOption,
TableColumn,
TableAction,
FormField,
FormErrors,
BreadcrumbItem,
MenuItem,
FilterOption,
ActiveFilter,
ModalProps,
ConfirmDialogProps,
BaseEntity,
AuditableEntity,
DeepPartial,
Nullable,
Optional,
} from './common.types';

221
web/src/utils/constants.ts Normal file
View File

@ -0,0 +1,221 @@
/**
* Constants - Application-wide constants and configuration values
*/
import type { StatusOption, SelectOption } from '../types';
import type {
FraccionamientoEstado,
EtapaStatus,
LoteStatus,
} from '../types/construction.types';
import type { PresupuestoEstado, EstimacionEstado } from '../types/estimates.types';
import type {
TipoIncidente,
GravedadIncidente,
EstadoIncidente,
TipoCapacitacion,
GravedadHallazgo,
} from '../types/hse.types';
// ===========================
// CONSTRUCTION STATUS OPTIONS
// ===========================
export const FRACCIONAMIENTO_ESTADO_OPTIONS: StatusOption<FraccionamientoEstado>[] = [
{ value: 'activo', label: 'Activo', color: 'green' },
{ value: 'pausado', label: 'Pausado', color: 'yellow' },
{ value: 'completado', label: 'Completado', color: 'blue' },
{ value: 'cancelado', label: 'Cancelado', color: 'red' },
];
export const ETAPA_STATUS_OPTIONS: StatusOption<EtapaStatus>[] = [
{ value: 'planned', label: 'Planeada', color: 'gray' },
{ value: 'in_progress', label: 'En Progreso', color: 'blue' },
{ value: 'completed', label: 'Completada', color: 'green' },
{ value: 'cancelled', label: 'Cancelada', color: 'red' },
];
export const LOTE_STATUS_OPTIONS: StatusOption<LoteStatus>[] = [
{ value: 'available', label: 'Disponible', color: 'green' },
{ value: 'reserved', label: 'Reservado', color: 'yellow' },
{ value: 'sold', label: 'Vendido', color: 'blue' },
{ value: 'blocked', label: 'Bloqueado', color: 'red' },
{ value: 'in_construction', label: 'En Construcción', color: 'purple' },
];
// ===========================
// ESTIMATES STATUS OPTIONS
// ===========================
export const PRESUPUESTO_ESTADO_OPTIONS: StatusOption<PresupuestoEstado>[] = [
{ value: 'borrador', label: 'Borrador', color: 'gray' },
{ value: 'revision', label: 'En Revisión', color: 'yellow' },
{ value: 'aprobado', label: 'Aprobado', color: 'green' },
{ value: 'cerrado', label: 'Cerrado', color: 'blue' },
];
export const ESTIMACION_ESTADO_OPTIONS: StatusOption<EstimacionEstado>[] = [
{ value: 'borrador', label: 'Borrador', color: 'gray' },
{ value: 'revision', label: 'En Revisión', color: 'yellow' },
{ value: 'aprobado', label: 'Aprobado', color: 'green' },
{ value: 'facturado', label: 'Facturado', color: 'blue' },
{ value: 'cobrado', label: 'Cobrado', color: 'purple' },
{ value: 'rechazado', label: 'Rechazado', color: 'red' },
];
// ===========================
// HSE STATUS OPTIONS
// ===========================
export const TIPO_INCIDENTE_OPTIONS: StatusOption<TipoIncidente>[] = [
{ value: 'accidente', label: 'Accidente', color: 'red' },
{ value: 'incidente', label: 'Incidente', color: 'orange' },
{ value: 'casi_accidente', label: 'Casi Accidente', color: 'yellow' },
];
export const GRAVEDAD_INCIDENTE_OPTIONS: StatusOption<GravedadIncidente>[] = [
{ value: 'leve', label: 'Leve', color: 'green' },
{ value: 'moderado', label: 'Moderado', color: 'yellow' },
{ value: 'grave', label: 'Grave', color: 'orange' },
{ value: 'fatal', label: 'Fatal', color: 'red' },
];
export const ESTADO_INCIDENTE_OPTIONS: StatusOption<EstadoIncidente>[] = [
{ value: 'abierto', label: 'Abierto', color: 'red' },
{ value: 'en_investigacion', label: 'En Investigación', color: 'yellow' },
{ value: 'cerrado', label: 'Cerrado', color: 'green' },
];
export const TIPO_CAPACITACION_OPTIONS: StatusOption<TipoCapacitacion>[] = [
{ value: 'induccion', label: 'Inducción', color: 'blue' },
{ value: 'especifica', label: 'Específica', color: 'purple' },
{ value: 'certificacion', label: 'Certificación', color: 'green' },
{ value: 'reentrenamiento', label: 'Reentrenamiento', color: 'yellow' },
];
export const GRAVEDAD_HALLAZGO_OPTIONS: StatusOption<GravedadHallazgo>[] = [
{ value: 'baja', label: 'Baja', color: 'green' },
{ value: 'media', label: 'Media', color: 'yellow' },
{ value: 'alta', label: 'Alta', color: 'orange' },
{ value: 'critica', label: 'Crítica', color: 'red' },
];
// ===========================
// PAGINATION
// ===========================
export const DEFAULT_PAGE_SIZE = 10;
export const PAGE_SIZE_OPTIONS: SelectOption<number>[] = [
{ value: 10, label: '10' },
{ value: 25, label: '25' },
{ value: 50, label: '50' },
{ value: 100, label: '100' },
];
// ===========================
// COLOR MAPS
// ===========================
export const STATUS_COLOR_CLASSES = {
gray: {
bg: 'bg-gray-100',
text: 'text-gray-800',
border: 'border-gray-200',
dot: 'bg-gray-500',
},
green: {
bg: 'bg-green-100',
text: 'text-green-800',
border: 'border-green-200',
dot: 'bg-green-500',
},
yellow: {
bg: 'bg-yellow-100',
text: 'text-yellow-800',
border: 'border-yellow-200',
dot: 'bg-yellow-500',
},
red: {
bg: 'bg-red-100',
text: 'text-red-800',
border: 'border-red-200',
dot: 'bg-red-500',
},
blue: {
bg: 'bg-blue-100',
text: 'text-blue-800',
border: 'border-blue-200',
dot: 'bg-blue-500',
},
purple: {
bg: 'bg-purple-100',
text: 'text-purple-800',
border: 'border-purple-200',
dot: 'bg-purple-500',
},
orange: {
bg: 'bg-orange-100',
text: 'text-orange-800',
border: 'border-orange-200',
dot: 'bg-orange-500',
},
pink: {
bg: 'bg-pink-100',
text: 'text-pink-800',
border: 'border-pink-200',
dot: 'bg-pink-500',
},
indigo: {
bg: 'bg-indigo-100',
text: 'text-indigo-800',
border: 'border-indigo-200',
dot: 'bg-indigo-500',
},
} as const;
// ===========================
// FILE UPLOAD
// ===========================
export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export const ALLOWED_DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
export const MAX_FILE_SIZE_MB = 10;
export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
// ===========================
// DATE FORMATS
// ===========================
export const DATE_FORMAT = 'dd/MM/yyyy';
export const DATETIME_FORMAT = 'dd/MM/yyyy HH:mm';
export const TIME_FORMAT = 'HH:mm';
export const API_DATE_FORMAT = 'yyyy-MM-dd';
// ===========================
// DEBOUNCE DELAYS
// ===========================
export const SEARCH_DEBOUNCE_MS = 300;
export const AUTOSAVE_DEBOUNCE_MS = 1000;
// ===========================
// MESSAGES
// ===========================
export const MESSAGES = {
// Success
CREATE_SUCCESS: 'Registro creado exitosamente',
UPDATE_SUCCESS: 'Registro actualizado exitosamente',
DELETE_SUCCESS: 'Registro eliminado exitosamente',
// Errors
GENERIC_ERROR: 'Ha ocurrido un error. Por favor, intente de nuevo.',
NETWORK_ERROR: 'Error de conexión. Verifique su conexión a internet.',
NOT_FOUND: 'El registro no fue encontrado.',
UNAUTHORIZED: 'No tiene permisos para realizar esta acción.',
VALIDATION_ERROR: 'Por favor, corrija los errores en el formulario.',
// Confirmations
DELETE_CONFIRM: '¿Está seguro que desea eliminar este registro?',
UNSAVED_CHANGES: 'Tiene cambios sin guardar. ¿Desea salir sin guardar?',
} as const;

215
web/src/utils/formatters.ts Normal file
View File

@ -0,0 +1,215 @@
/**
* Formatters - Utility functions for formatting values
*/
/**
* Format a number as Mexican Peso currency
*/
export function formatCurrency(
value: number,
options?: { decimals?: number; showSymbol?: boolean }
): string {
const { decimals = 0, showSymbol = true } = options ?? {};
if (showSymbol) {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
/**
* Format a decimal value as percentage
* @param value - Decimal value (e.g., 0.85 = 85%)
* @param decimals - Number of decimal places
*/
export function formatPercent(value: number, decimals: number = 1): string {
return `${(value * 100).toFixed(decimals)}%`;
}
/**
* Format a number with locale-specific formatting
*/
export function formatNumber(value: number, decimals: number = 2): string {
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
/**
* Format a number with compact notation (K, M, B)
*/
export function formatCompactNumber(value: number): string {
return new Intl.NumberFormat('es-MX', {
notation: 'compact',
compactDisplay: 'short',
}).format(value);
}
/**
* Format an area in square meters
*/
export function formatArea(value: number, decimals: number = 2): string {
return `${formatNumber(value, decimals)}`;
}
/**
* Format a date string to locale format
*/
export function formatDate(
date: string | Date | null | undefined,
options?: Intl.DateTimeFormatOptions
): string {
if (!date) return '-';
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
...options,
};
try {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('es-MX', defaultOptions).format(dateObj);
} catch {
return '-';
}
}
/**
* Format a date to short format (DD/MM/YYYY)
*/
export function formatDateShort(date: string | Date | null | undefined): string {
if (!date) return '-';
try {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(dateObj);
} catch {
return '-';
}
}
/**
* Format a date with time
*/
export function formatDateTime(date: string | Date | null | undefined): string {
if (!date) return '-';
try {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(dateObj);
} catch {
return '-';
}
}
/**
* Format a time string (HH:mm)
*/
export function formatTime(time: string | null | undefined): string {
if (!time) return '-';
return time;
}
/**
* Get relative time (hace X días, etc.)
*/
export function formatRelativeTime(date: string | Date | null | undefined): string {
if (!date) return '-';
try {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - dateObj.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Hoy';
if (diffDays === 1) return 'Ayer';
if (diffDays < 7) return `Hace ${diffDays} días`;
if (diffDays < 30) return `Hace ${Math.floor(diffDays / 7)} semanas`;
if (diffDays < 365) return `Hace ${Math.floor(diffDays / 30)} meses`;
return `Hace ${Math.floor(diffDays / 365)} años`;
} catch {
return '-';
}
}
/**
* Format a period string (e.g., "2024-01" to "Enero 2024")
*/
export function formatPeriod(period: string | null | undefined): string {
if (!period) return '-';
try {
const [year, month] = period.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
return new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: 'long',
}).format(date);
} catch {
return period;
}
}
/**
* Truncate text with ellipsis
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength)}...`;
}
/**
* Format a name (firstName lastName)
*/
export function formatFullName(
firstName: string | null | undefined,
lastName: string | null | undefined
): string {
const parts = [firstName, lastName].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : '-';
}
/**
* Format bytes to human readable format
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/**
* Format duration in hours
*/
export function formatDuration(hours: number): string {
if (hours < 1) return `${Math.round(hours * 60)} min`;
if (hours === 1) return '1 hora';
return `${formatNumber(hours, 1)} horas`;
}

76
web/src/utils/index.ts Normal file
View File

@ -0,0 +1,76 @@
/**
* Utils Index - Central export for all utility functions and constants
*/
// Formatters
export {
formatCurrency,
formatPercent,
formatNumber,
formatCompactNumber,
formatArea,
formatDate,
formatDateShort,
formatDateTime,
formatTime,
formatRelativeTime,
formatPeriod,
truncateText,
formatFullName,
formatFileSize,
formatDuration,
} from './formatters';
// Validators
export {
isValidEmail,
isValidRFC,
isValidCURP,
isValidPhoneNumber,
isValidPostalCode,
isNonEmpty,
isPositiveNumber,
isNonNegativeNumber,
isInRange,
isValidPercentage,
isValidDate,
isNotFutureDate,
isNotPastDate,
isEndDateAfterStartDate,
isValidUrl,
isValidCode,
hasMinLength,
hasMaxLength,
isStrongPassword,
getPasswordStrength,
isValidFileExtension,
isValidFileSize,
} from './validators';
// Constants
export {
FRACCIONAMIENTO_ESTADO_OPTIONS,
ETAPA_STATUS_OPTIONS,
LOTE_STATUS_OPTIONS,
PRESUPUESTO_ESTADO_OPTIONS,
ESTIMACION_ESTADO_OPTIONS,
TIPO_INCIDENTE_OPTIONS,
GRAVEDAD_INCIDENTE_OPTIONS,
ESTADO_INCIDENTE_OPTIONS,
TIPO_CAPACITACION_OPTIONS,
GRAVEDAD_HALLAZGO_OPTIONS,
DEFAULT_PAGE_SIZE,
PAGE_SIZE_OPTIONS,
STATUS_COLOR_CLASSES,
ALLOWED_IMAGE_EXTENSIONS,
ALLOWED_DOCUMENT_EXTENSIONS,
MAX_FILE_SIZE_MB,
MAX_FILE_SIZE_BYTES,
DATE_FORMAT,
DATETIME_FORMAT,
TIME_FORMAT,
API_DATE_FORMAT,
SEARCH_DEBOUNCE_MS,
AUTOSAVE_DEBOUNCE_MS,
MESSAGES,
} from './constants';

215
web/src/utils/validators.ts Normal file
View File

@ -0,0 +1,215 @@
/**
* Validators - Utility functions for validating values
*/
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Validate Mexican RFC format (Registro Federal de Contribuyentes)
* - Persona Física: 13 caracteres (4 letras + 6 dígitos fecha + 3 homoclave)
* - Persona Moral: 12 caracteres (3 letras + 6 dígitos fecha + 3 homoclave)
*/
export function isValidRFC(rfc: string): boolean {
const rfcPersonaFisica = /^[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}$/;
const rfcPersonaMoral = /^[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}$/;
const upperRFC = rfc.toUpperCase().replace(/\s/g, '');
return rfcPersonaFisica.test(upperRFC) || rfcPersonaMoral.test(upperRFC);
}
/**
* Validate Mexican CURP format (Clave Única de Registro de Población)
* 18 caracteres alfanuméricos
*/
export function isValidCURP(curp: string): boolean {
const curpRegex = /^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z0-9]\d$/;
return curpRegex.test(curp.toUpperCase().replace(/\s/g, ''));
}
/**
* Validate Mexican phone number (10 digits)
*/
export function isValidPhoneNumber(phone: string): boolean {
const digits = phone.replace(/\D/g, '');
return digits.length === 10;
}
/**
* Validate Mexican postal code (5 digits)
*/
export function isValidPostalCode(postalCode: string): boolean {
const postalCodeRegex = /^\d{5}$/;
return postalCodeRegex.test(postalCode);
}
/**
* Validate a non-empty string
*/
export function isNonEmpty(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim().length > 0;
}
/**
* Validate a positive number
*/
export function isPositiveNumber(value: number): boolean {
return !isNaN(value) && value > 0;
}
/**
* Validate a non-negative number
*/
export function isNonNegativeNumber(value: number): boolean {
return !isNaN(value) && value >= 0;
}
/**
* Validate a number is within a range
*/
export function isInRange(value: number, min: number, max: number): boolean {
return !isNaN(value) && value >= min && value <= max;
}
/**
* Validate a percentage value (0-100)
*/
export function isValidPercentage(value: number): boolean {
return isInRange(value, 0, 100);
}
/**
* Validate a date string
*/
export function isValidDate(dateString: string): boolean {
const date = new Date(dateString);
return !isNaN(date.getTime());
}
/**
* Validate that a date is not in the future
*/
export function isNotFutureDate(dateString: string): boolean {
const date = new Date(dateString);
const now = new Date();
return date <= now;
}
/**
* Validate that a date is not in the past
*/
export function isNotPastDate(dateString: string): boolean {
const date = new Date(dateString);
const today = new Date();
today.setHours(0, 0, 0, 0);
return date >= today;
}
/**
* Validate that end date is after start date
*/
export function isEndDateAfterStartDate(startDate: string, endDate: string): boolean {
const start = new Date(startDate);
const end = new Date(endDate);
return end > start;
}
/**
* Validate a URL format
*/
export function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Validate an alphanumeric code (letters, numbers, dashes, underscores)
*/
export function isValidCode(code: string): boolean {
const codeRegex = /^[A-Za-z0-9_-]+$/;
return codeRegex.test(code);
}
/**
* Validate minimum length
*/
export function hasMinLength(value: string, minLength: number): boolean {
return value.length >= minLength;
}
/**
* Validate maximum length
*/
export function hasMaxLength(value: string, maxLength: number): boolean {
return value.length <= maxLength;
}
/**
* Validate password strength
* - At least 8 characters
* - At least one uppercase letter
* - At least one lowercase letter
* - At least one number
*/
export function isStrongPassword(password: string): boolean {
const minLength = password.length >= 8;
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
return minLength && hasUppercase && hasLowercase && hasNumber;
}
/**
* Get password strength score (0-4)
*/
export function getPasswordStrength(password: string): {
score: number;
label: 'Muy débil' | 'Débil' | 'Regular' | 'Fuerte' | 'Muy fuerte';
} {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
const labels: Array<'Muy débil' | 'Débil' | 'Regular' | 'Fuerte' | 'Muy fuerte'> = [
'Muy débil',
'Débil',
'Regular',
'Fuerte',
'Muy fuerte',
];
return {
score: Math.min(score, 4),
label: labels[Math.min(score, 4)],
};
}
/**
* Validate file extension
*/
export function isValidFileExtension(
filename: string,
allowedExtensions: string[]
): boolean {
const extension = filename.split('.').pop()?.toLowerCase() ?? '';
return allowedExtensions.map((ext) => ext.toLowerCase()).includes(extension);
}
/**
* Validate file size (in bytes)
*/
export function isValidFileSize(fileSize: number, maxSizeBytes: number): boolean {
return fileSize <= maxSizeBytes;
}