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:
parent
b93f4c5797
commit
765a639004
168
web/src/components/common/ActionButtons.tsx
Normal file
168
web/src/components/common/ActionButtons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
web/src/components/common/ConfirmDialog.tsx
Normal file
104
web/src/components/common/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
web/src/components/common/DataTable.tsx
Normal file
209
web/src/components/common/DataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
web/src/components/common/EmptyState.tsx
Normal file
31
web/src/components/common/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
web/src/components/common/FormField.tsx
Normal file
222
web/src/components/common/FormField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
web/src/components/common/LoadingSpinner.tsx
Normal file
45
web/src/components/common/LoadingSpinner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
web/src/components/common/Modal.tsx
Normal file
123
web/src/components/common/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web/src/components/common/PageHeader.tsx
Normal file
73
web/src/components/common/PageHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
web/src/components/common/SearchInput.tsx
Normal file
70
web/src/components/common/SearchInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
web/src/components/common/StatusBadge.tsx
Normal file
87
web/src/components/common/StatusBadge.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
web/src/components/common/index.ts
Normal file
36
web/src/components/common/index.ts
Normal 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';
|
||||||
@ -1,15 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* IncidentesPage - HSE Incidents Management
|
||||||
|
* Refactored to use common components
|
||||||
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { Plus, Pencil, Eye, FileText, XCircle, AlertTriangle, Activity } from 'lucide-react';
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Eye,
|
|
||||||
Search,
|
|
||||||
FileText,
|
|
||||||
XCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Activity,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
useIncidentes,
|
useIncidentes,
|
||||||
useCreateIncidente,
|
useCreateIncidente,
|
||||||
@ -18,53 +14,35 @@ import {
|
|||||||
useCloseIncidente,
|
useCloseIncidente,
|
||||||
} from '../../../hooks/useHSE';
|
} from '../../../hooks/useHSE';
|
||||||
import { useFraccionamientos } from '../../../hooks/useConstruccion';
|
import { useFraccionamientos } from '../../../hooks/useConstruccion';
|
||||||
import { Fraccionamiento } from '../../../services/construccion/fraccionamientos.api';
|
import type {
|
||||||
import {
|
|
||||||
Incidente,
|
Incidente,
|
||||||
TipoIncidente,
|
TipoIncidente,
|
||||||
GravedadIncidente,
|
GravedadIncidente,
|
||||||
EstadoIncidente,
|
EstadoIncidente,
|
||||||
CreateIncidenteDto,
|
CreateIncidenteDto,
|
||||||
} from '../../../services/hse/incidentes.api';
|
Fraccionamiento,
|
||||||
import clsx from 'clsx';
|
} from '../../../types';
|
||||||
|
import {
|
||||||
const tipoColors: Record<TipoIncidente, string> = {
|
PageHeader,
|
||||||
accidente: 'bg-red-100 text-red-800',
|
PageHeaderAction,
|
||||||
incidente: 'bg-yellow-100 text-yellow-800',
|
DataTable,
|
||||||
casi_accidente: 'bg-blue-100 text-blue-800',
|
SearchInput,
|
||||||
};
|
SelectField,
|
||||||
|
StatusBadgeFromOptions,
|
||||||
const tipoLabels: Record<TipoIncidente, string> = {
|
Modal,
|
||||||
accidente: 'Accidente',
|
ModalFooter,
|
||||||
incidente: 'Incidente',
|
TextInput,
|
||||||
casi_accidente: 'Casi Accidente',
|
TextareaField,
|
||||||
};
|
FormGroup,
|
||||||
|
} from '../../../components/common';
|
||||||
const gravedadColors: Record<GravedadIncidente, string> = {
|
import type { DataTableColumn } from '../../../components/common';
|
||||||
leve: 'bg-green-100 text-green-800',
|
import {
|
||||||
moderado: 'bg-yellow-100 text-yellow-800',
|
TIPO_INCIDENTE_OPTIONS,
|
||||||
grave: 'bg-orange-100 text-orange-800',
|
GRAVEDAD_INCIDENTE_OPTIONS,
|
||||||
fatal: 'bg-red-100 text-red-800',
|
ESTADO_INCIDENTE_OPTIONS,
|
||||||
};
|
formatDate,
|
||||||
|
truncateText,
|
||||||
const gravedadLabels: Record<GravedadIncidente, string> = {
|
} from '../../../utils';
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function IncidentesPage() {
|
export function IncidentesPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -90,7 +68,6 @@ export function IncidentesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: fraccionamientosData } = useFraccionamientos();
|
const { data: fraccionamientosData } = useFraccionamientos();
|
||||||
|
|
||||||
const createMutation = useCreateIncidente();
|
const createMutation = useCreateIncidente();
|
||||||
const updateMutation = useUpdateIncidente();
|
const updateMutation = useUpdateIncidente();
|
||||||
const investigateMutation = useInvestigateIncidente();
|
const investigateMutation = useInvestigateIncidente();
|
||||||
@ -107,301 +84,79 @@ export function IncidentesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInvestigate = async (id: string, investigadorId: string) => {
|
const handleInvestigate = async (id: string, investigadorId: string) => {
|
||||||
await investigateMutation.mutateAsync({
|
await investigateMutation.mutateAsync({ id, investigadorId, fechaInvestigacion: new Date().toISOString() });
|
||||||
id,
|
|
||||||
investigadorId,
|
|
||||||
fechaInvestigacion: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
setInvestigateModal(null);
|
setInvestigateModal(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = async (id: string, observaciones?: string) => {
|
const handleClose = async (id: string, observaciones?: string) => {
|
||||||
await closeMutation.mutateAsync({
|
await closeMutation.mutateAsync({ id, fechaCierre: new Date().toISOString(), observaciones });
|
||||||
id,
|
|
||||||
fechaCierre: new Date().toISOString(),
|
|
||||||
observaciones,
|
|
||||||
});
|
|
||||||
setCloseModal(null);
|
setCloseModal(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const incidentes = data?.items || [];
|
const incidentes = data?.items || [];
|
||||||
const fraccionamientos = fraccionamientosData?.items || [];
|
const fraccionamientos = fraccionamientosData?.items || [];
|
||||||
|
|
||||||
// Generate folio for display (assuming incremental numbering)
|
const generateFolio = (index: number) => `INC-${new Date().getFullYear()}-${String(index + 1).padStart(4, '0')}`;
|
||||||
const generateFolio = (index: number) => {
|
|
||||||
const year = new Date().getFullYear();
|
const columns: DataTableColumn<Incidente>[] = [
|
||||||
return `INC-${year}-${String(index + 1).padStart(4, '0')}`;
|
{ 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<PageHeader
|
||||||
<div>
|
title="Incidentes HSE"
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Incidentes HSE</h1>
|
description="Gestión de incidentes de seguridad, salud y medio ambiente"
|
||||||
<p className="text-gray-600">Gestión de incidentes de seguridad, salud y medio ambiente</p>
|
actions={<PageHeaderAction onClick={() => { setEditingItem(null); setShowModal(true); }}><Plus className="w-5 h-5 mr-2" />Registrar Incidente</PageHeaderAction>}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Filters */}
|
<div className="bg-white rounded-lg shadow-sm p-4 mb-6 space-y-4">
|
||||||
<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 flex-col gap-4">
|
<SearchInput value={search} onChange={setSearch} placeholder="Buscar por folio o descripción..." className="flex-1" />
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<SelectField options={[{ value: '', label: 'Todos los fraccionamientos' }, ...fracOptions]} value={fraccionamientoFilter} onChange={(e) => setFraccionamientoFilter(e.target.value)} className="sm:w-56" />
|
||||||
<div className="flex-1 relative">
|
</div>
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<div className="flex flex-wrap gap-4">
|
||||||
<input
|
<SelectField options={[{ value: '', label: 'Todos los tipos' }, ...tipoOptions]} value={tipoFilter} onChange={(e) => setTipoFilter(e.target.value as TipoIncidente | '')} className="w-40" />
|
||||||
type="text"
|
<SelectField options={[{ value: '', label: 'Todas las gravedades' }, ...gravedadOptions]} value={gravedadFilter} onChange={(e) => setGravedadFilter(e.target.value as GravedadIncidente | '')} className="w-48" />
|
||||||
placeholder="Buscar por folio o descripción..."
|
<SelectField options={[{ value: '', label: 'Todos los estados' }, ...estadoOptions]} value={estadoFilter} onChange={(e) => setEstadoFilter(e.target.value as EstadoIncidente | '')} className="w-48" />
|
||||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
<TextInput type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-40" />
|
||||||
value={search}
|
<TextInput type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-40" />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
<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.' }} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{showModal && <IncidenteModal item={editingItem} fraccionamientos={fraccionamientos} onClose={() => { setShowModal(false); setEditingItem(null); }} onSubmit={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending} />}
|
||||||
{showModal && (
|
{investigateModal && <InvestigateModal onClose={() => setInvestigateModal(null)} onSubmit={(investigadorId) => handleInvestigate(investigateModal.id, investigadorId)} isLoading={investigateMutation.isPending} />}
|
||||||
<IncidenteModal
|
{closeModal && <CloseModal onClose={() => setCloseModal(null)} onSubmit={(observaciones) => handleClose(closeModal.id, observaciones)} isLoading={closeMutation.isPending} />}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal Component
|
// Incidente Modal Component
|
||||||
interface IncidenteModalProps {
|
interface IncidenteModalProps {
|
||||||
item: Incidente | null;
|
item: Incidente | null;
|
||||||
fraccionamientos: Fraccionamiento[];
|
fraccionamientos: Fraccionamiento[];
|
||||||
@ -410,13 +165,7 @@ interface IncidenteModalProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IncidenteModal({
|
function IncidenteModal({ item, fraccionamientos, onClose, onSubmit, isLoading }: IncidenteModalProps) {
|
||||||
item,
|
|
||||||
fraccionamientos,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
isLoading,
|
|
||||||
}: IncidenteModalProps) {
|
|
||||||
const [formData, setFormData] = useState<CreateIncidenteDto>({
|
const [formData, setFormData] = useState<CreateIncidenteDto>({
|
||||||
fraccionamientoId: item?.fraccionamientoId || '',
|
fraccionamientoId: item?.fraccionamientoId || '',
|
||||||
tipo: item?.tipo || 'incidente',
|
tipo: item?.tipo || 'incidente',
|
||||||
@ -430,311 +179,74 @@ function IncidenteModal({
|
|||||||
observaciones: item?.observaciones || '',
|
observaciones: item?.observaciones || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await onSubmit(formData); };
|
||||||
e.preventDefault();
|
const update = (field: keyof CreateIncidenteDto, value: string) => setFormData({ ...formData, [field]: value });
|
||||||
await onSubmit(formData);
|
|
||||||
};
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal isOpen={true} onClose={onClose} title={item ? 'Editar Incidente' : 'Registrar Nuevo Incidente'} size="lg"
|
||||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
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>}>
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<form id="incidente-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
{item ? 'Editar Incidente' : 'Registrar Nuevo Incidente'}
|
<FormGroup cols={2}>
|
||||||
</h3>
|
<TextInput label="Fecha" type="date" required value={formData.fecha} onChange={(e) => update('fecha', e.target.value)} />
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<TextInput label="Hora" type="time" value={formData.hora || ''} onChange={(e) => update('hora', e.target.value)} />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</FormGroup>
|
||||||
<div>
|
<SelectField label="Fraccionamiento" required options={[{ value: '', label: 'Seleccione un fraccionamiento' }, ...fracOptions]} value={formData.fraccionamientoId} onChange={(e) => update('fraccionamientoId', e.target.value)} />
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<FormGroup cols={2}>
|
||||||
Fecha *
|
<SelectField label="Tipo" required options={tipoOptions} value={formData.tipo} onChange={(e) => update('tipo', e.target.value)} />
|
||||||
</label>
|
<SelectField label="Gravedad" required options={gravedadOptions} value={formData.gravedad} onChange={(e) => update('gravedad', e.target.value)} />
|
||||||
<input
|
</FormGroup>
|
||||||
type="date"
|
<TextInput label="Ubicación" placeholder="Ej: Zona de excavación, Área de cimbra, etc." value={formData.ubicacion || ''} onChange={(e) => update('ubicacion', e.target.value)} />
|
||||||
required
|
<TextareaField label="Descripción Detallada" required placeholder="Describa detalladamente lo sucedido..." value={formData.descripcion} onChange={(e) => update('descripcion', e.target.value)} />
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
<TextareaField label="Causa Inmediata" rows={2} placeholder="¿Qué causó directamente el incidente?" value={formData.causas || ''} onChange={(e) => update('causas', e.target.value)} />
|
||||||
value={formData.fecha}
|
<TextareaField label="Acciones Inmediatas Tomadas" rows={2} placeholder="¿Qué acciones se tomaron de inmediato?" value={formData.acciones || ''} onChange={(e) => update('acciones', e.target.value)} />
|
||||||
onChange={(e) => setFormData({ ...formData, fecha: e.target.value })}
|
<TextareaField label="Observaciones" rows={2} placeholder="Información adicional..." value={formData.observaciones || ''} onChange={(e) => update('observaciones', e.target.value)} />
|
||||||
/>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Investigate Modal
|
// Investigate Modal
|
||||||
interface InvestigateModalProps {
|
interface InvestigateModalProps { onClose: () => void; onSubmit: (investigadorId: string) => Promise<void>; isLoading: boolean; }
|
||||||
incidente: Incidente;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (investigadorId: string) => Promise<void>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InvestigateModal({ onClose, onSubmit, isLoading }: InvestigateModalProps) {
|
function InvestigateModal({ onClose, onSubmit, isLoading }: InvestigateModalProps) {
|
||||||
const [investigadorId, setInvestigadorId] = useState('');
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal isOpen={true} onClose={onClose} title="Iniciar Investigación" size="sm">
|
||||||
<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" /><span className="text-gray-600">Asigne un investigador responsable.</span></div>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<form onSubmit={handleSubmit}>
|
||||||
<AlertTriangle className="w-6 h-6 text-purple-600" />
|
<TextInput label="Investigador Responsable" required placeholder="ID del investigador" value={investigadorId} onChange={(e) => setInvestigadorId(e.target.value)} className="mb-4" />
|
||||||
<h3 className="text-lg font-semibold">Iniciar Investigación</h3>
|
<ModalFooter>
|
||||||
</div>
|
<button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button>
|
||||||
<p className="text-gray-600 mb-4">
|
<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>
|
||||||
Asigne un investigador responsable para iniciar el proceso de investigación del
|
</ModalFooter>
|
||||||
incidente.
|
</form>
|
||||||
</p>
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close Modal
|
// Close Modal
|
||||||
interface CloseModalProps {
|
interface CloseModalProps { onClose: () => void; onSubmit: (observaciones?: string) => Promise<void>; isLoading: boolean; }
|
||||||
incidente: Incidente;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (observaciones?: string) => Promise<void>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloseModal({ onClose, onSubmit, isLoading }: CloseModalProps) {
|
function CloseModal({ onClose, onSubmit, isLoading }: CloseModalProps) {
|
||||||
const [observaciones, setObservaciones] = useState('');
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal isOpen={true} onClose={onClose} title="Cerrar Incidente" size="sm">
|
||||||
<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" /><span className="text-gray-600">Complete la investigación y cierre el incidente.</span></div>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<form onSubmit={handleSubmit}>
|
||||||
<Activity className="w-6 h-6 text-green-600" />
|
<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" />
|
||||||
<h3 className="text-lg font-semibold">Cerrar Incidente</h3>
|
<ModalFooter>
|
||||||
</div>
|
<button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button>
|
||||||
<p className="text-gray-600 mb-4">
|
<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>
|
||||||
Complete la investigación y cierre el incidente. Agregue observaciones finales si es
|
</ModalFooter>
|
||||||
necesario.
|
</form>
|
||||||
</p>
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* PresupuestosPage - Management of construction budgets
|
||||||
|
* Refactored to use common components
|
||||||
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { Plus, Pencil, Trash2, Copy, CheckCircle, Eye } from 'lucide-react';
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Search,
|
|
||||||
Copy,
|
|
||||||
CheckCircle,
|
|
||||||
Eye,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
usePresupuestos,
|
usePresupuestos,
|
||||||
useCreatePresupuesto,
|
useCreatePresupuesto,
|
||||||
@ -17,34 +14,30 @@ import {
|
|||||||
useApprovePresupuesto,
|
useApprovePresupuesto,
|
||||||
useDuplicatePresupuesto,
|
useDuplicatePresupuesto,
|
||||||
} from '../../../hooks/usePresupuestos';
|
} from '../../../hooks/usePresupuestos';
|
||||||
|
import type { Presupuesto, PresupuestoEstado, CreatePresupuestoDto } from '../../../types';
|
||||||
import {
|
import {
|
||||||
Presupuesto,
|
PageHeader,
|
||||||
PresupuestoEstado,
|
PageHeaderAction,
|
||||||
CreatePresupuestoDto,
|
DataTable,
|
||||||
} from '../../../services/presupuestos';
|
SearchInput,
|
||||||
import clsx from 'clsx';
|
SelectField,
|
||||||
|
StatusBadgeFromOptions,
|
||||||
const estadoColors: Record<PresupuestoEstado, string> = {
|
ConfirmDialog,
|
||||||
borrador: 'bg-gray-100 text-gray-800',
|
Modal,
|
||||||
revision: 'bg-yellow-100 text-yellow-800',
|
ModalFooter,
|
||||||
aprobado: 'bg-green-100 text-green-800',
|
TextInput,
|
||||||
cerrado: 'bg-blue-100 text-blue-800',
|
FormGroup,
|
||||||
};
|
} from '../../../components/common';
|
||||||
|
import type { DataTableColumn } from '../../../components/common';
|
||||||
const estadoLabels: Record<PresupuestoEstado, string> = {
|
import { PRESUPUESTO_ESTADO_OPTIONS, formatCurrency } from '../../../utils';
|
||||||
borrador: 'Borrador',
|
|
||||||
revision: 'En Revision',
|
|
||||||
aprobado: 'Aprobado',
|
|
||||||
cerrado: 'Cerrado',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PresupuestosPage() {
|
export function PresupuestosPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [estadoFilter, setEstadoFilter] = useState<PresupuestoEstado | ''>('');
|
const [estadoFilter, setEstadoFilter] = useState<PresupuestoEstado | ''>('');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState<Presupuesto | null>(null);
|
const [editingItem, setEditingItem] = useState<Presupuesto | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [approveConfirm, setApproveConfirm] = useState<string | null>(null);
|
const [approveId, setApproveId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error } = usePresupuestos({
|
const { data, isLoading, error } = usePresupuestos({
|
||||||
estado: estadoFilter || undefined,
|
estado: estadoFilter || undefined,
|
||||||
@ -56,30 +49,23 @@ export function PresupuestosPage() {
|
|||||||
const approveMutation = useApprovePresupuesto();
|
const approveMutation = useApprovePresupuesto();
|
||||||
const duplicateMutation = useDuplicatePresupuesto();
|
const duplicateMutation = useDuplicatePresupuesto();
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async () => {
|
||||||
await deleteMutation.mutateAsync(id);
|
if (deleteId) {
|
||||||
setDeleteConfirm(null);
|
await deleteMutation.mutateAsync(deleteId);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApprove = async (id: string) => {
|
const handleApprove = async () => {
|
||||||
await approveMutation.mutateAsync(id);
|
if (approveId) {
|
||||||
setApproveConfirm(null);
|
await approveMutation.mutateAsync(approveId);
|
||||||
};
|
setApproveId(null);
|
||||||
|
}
|
||||||
const handleDuplicate = async (id: string) => {
|
|
||||||
await duplicateMutation.mutateAsync(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (formData: CreatePresupuestoDto) => {
|
const handleSubmit = async (formData: CreatePresupuestoDto) => {
|
||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
await updateMutation.mutateAsync({
|
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||||
id: editingItem.id,
|
|
||||||
data: {
|
|
||||||
codigo: formData.codigo,
|
|
||||||
nombre: formData.nombre,
|
|
||||||
estado: formData.estado,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(formData);
|
await createMutation.mutateAsync(formData);
|
||||||
}
|
}
|
||||||
@ -87,238 +73,153 @@ export function PresupuestosPage() {
|
|||||||
setEditingItem(null);
|
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(
|
const presupuestos = (data?.items || []).filter(
|
||||||
(p) =>
|
(p) => !search ||
|
||||||
!search ||
|
|
||||||
p.codigo.toLowerCase().includes(search.toLowerCase()) ||
|
p.codigo.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
p.nombre.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<PageHeader
|
||||||
<div>
|
title="Presupuestos"
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Presupuestos</h1>
|
description="Gestión de presupuestos de obra"
|
||||||
<p className="text-gray-600">Gestion de presupuestos de obra</p>
|
actions={
|
||||||
</div>
|
<PageHeaderAction onClick={openCreate}>
|
||||||
<button
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
Nuevo Presupuesto
|
||||||
onClick={() => {
|
</PageHeaderAction>
|
||||||
setEditingItem(null);
|
}
|
||||||
setShowModal(true);
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5 mr-2" />
|
|
||||||
Nuevo Presupuesto
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
<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 flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<SearchInput
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
value={search}
|
||||||
<input
|
onChange={setSearch}
|
||||||
type="text"
|
placeholder="Buscar por código o nombre..."
|
||||||
placeholder="Buscar por codigo o nombre..."
|
className="flex-1"
|
||||||
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}
|
<SelectField
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
options={[{ value: '', label: 'Todos los estados' }, ...filterOptions]}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
value={estadoFilter}
|
value={estadoFilter}
|
||||||
onChange={(e) => setEstadoFilter(e.target.value as PresupuestoEstado | '')}
|
onChange={(e) => setEstadoFilter(e.target.value as PresupuestoEstado | '')}
|
||||||
>
|
className="sm:w-48"
|
||||||
<option value="">Todos los estados</option>
|
/>
|
||||||
{Object.entries(estadoLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
<DataTable
|
||||||
{isLoading ? (
|
data={presupuestos}
|
||||||
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
columns={columns}
|
||||||
) : error ? (
|
isLoading={isLoading}
|
||||||
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
|
error={error ? 'Error al cargar los datos' : null}
|
||||||
) : presupuestos.length === 0 ? (
|
emptyState={{ title: 'No hay presupuestos', description: 'Crea el primer presupuesto para comenzar.' }}
|
||||||
<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>
|
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<PresupuestoModal
|
<PresupuestoModal
|
||||||
item={editingItem}
|
item={editingItem}
|
||||||
onClose={() => {
|
onClose={() => { setShowModal(false); setEditingItem(null); }}
|
||||||
setShowModal(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteConfirm && (
|
<ConfirmDialog
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
isOpen={!!deleteId}
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
onClose={() => setDeleteId(null)}
|
||||||
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
onConfirm={handleDelete}
|
||||||
<p className="text-gray-600 mb-6">
|
title="Confirmar eliminación"
|
||||||
¿Esta seguro de eliminar este presupuesto? Esta accion no se puede deshacer.
|
message="¿Está seguro de eliminar este presupuesto? Esta acción no se puede deshacer."
|
||||||
</p>
|
confirmLabel="Eliminar"
|
||||||
<div className="flex justify-end gap-3">
|
variant="danger"
|
||||||
<button
|
isLoading={deleteMutation.isPending}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{approveConfirm && (
|
<ConfirmDialog
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
isOpen={!!approveId}
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
onClose={() => setApproveId(null)}
|
||||||
<h3 className="text-lg font-semibold mb-4">Confirmar aprobacion</h3>
|
onConfirm={handleApprove}
|
||||||
<p className="text-gray-600 mb-6">
|
title="Confirmar aprobación"
|
||||||
¿Esta seguro de aprobar este presupuesto? Una vez aprobado, no podra ser editado.
|
message="¿Está seguro de aprobar este presupuesto? Una vez aprobado, no podrá ser editado."
|
||||||
</p>
|
confirmLabel="Aprobar"
|
||||||
<div className="flex justify-end gap-3">
|
variant="info"
|
||||||
<button
|
isLoading={approveMutation.isPending}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modal Component
|
||||||
interface PresupuestoModalProps {
|
interface PresupuestoModalProps {
|
||||||
item: Presupuesto | null;
|
item: Presupuesto | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -339,73 +240,58 @@ function PresupuestoModal({ item, onClose, onSubmit, isLoading }: PresupuestoMod
|
|||||||
await onSubmit(formData);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal
|
||||||
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
isOpen={true}
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
onClose={onClose}
|
||||||
{item ? 'Editar Presupuesto' : 'Nuevo Presupuesto'}
|
title={item ? 'Editar Presupuesto' : 'Nuevo Presupuesto'}
|
||||||
</h3>
|
size="md"
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
footer={
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<ModalFooter>
|
||||||
<div>
|
<button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
|
Cancelar
|
||||||
<input
|
</button>
|
||||||
type="text"
|
<button
|
||||||
required
|
type="submit"
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
form="presupuesto-form"
|
||||||
value={formData.codigo}
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
|
disabled={isLoading}
|
||||||
/>
|
>
|
||||||
</div>
|
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||||
<div>
|
</button>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
</ModalFooter>
|
||||||
<select
|
}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
>
|
||||||
value={formData.estado}
|
<form id="presupuesto-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
onChange={(e) =>
|
<FormGroup cols={2}>
|
||||||
setFormData({ ...formData, estado: e.target.value as PresupuestoEstado })
|
<TextInput
|
||||||
}
|
label="Código"
|
||||||
>
|
required
|
||||||
{editableStates.map((estado) => (
|
value={formData.codigo}
|
||||||
<option key={estado} value={estado}>
|
onChange={(e) => update('codigo', e.target.value)}
|
||||||
{estadoLabels[estado]}
|
/>
|
||||||
</option>
|
<SelectField
|
||||||
))}
|
label="Estado"
|
||||||
</select>
|
options={editableStates}
|
||||||
</div>
|
value={formData.estado || 'borrador'}
|
||||||
</div>
|
onChange={(e) => update('estado', e.target.value)}
|
||||||
|
/>
|
||||||
<div>
|
</FormGroup>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
<TextInput
|
||||||
<input
|
label="Nombre"
|
||||||
type="text"
|
required
|
||||||
required
|
value={formData.nombre}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
onChange={(e) => update('nombre', e.target.value)}
|
||||||
value={formData.nombre}
|
/>
|
||||||
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
</form>
|
||||||
/>
|
</Modal>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* FraccionamientosPage - Management of fraccionamientos (developments)
|
||||||
|
* Refactored to use common components
|
||||||
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react';
|
import { Plus, Eye, Pencil, Trash2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFraccionamientos,
|
useFraccionamientos,
|
||||||
useDeleteFraccionamiento,
|
useDeleteFraccionamiento,
|
||||||
useCreateFraccionamiento,
|
useCreateFraccionamiento,
|
||||||
useUpdateFraccionamiento,
|
useUpdateFraccionamiento,
|
||||||
} from '../../../hooks/useConstruccion';
|
} from '../../../hooks/useConstruccion';
|
||||||
import {
|
import type {
|
||||||
Fraccionamiento,
|
Fraccionamiento,
|
||||||
FraccionamientoEstado,
|
FraccionamientoEstado,
|
||||||
CreateFraccionamientoDto,
|
CreateFraccionamientoDto,
|
||||||
} from '../../../services/construccion/fraccionamientos.api';
|
} from '../../../types';
|
||||||
import clsx from 'clsx';
|
import {
|
||||||
|
PageHeader,
|
||||||
const estadoColors: Record<FraccionamientoEstado, string> = {
|
PageHeaderAction,
|
||||||
activo: 'bg-green-100 text-green-800',
|
DataTable,
|
||||||
pausado: 'bg-yellow-100 text-yellow-800',
|
SearchInput,
|
||||||
completado: 'bg-blue-100 text-blue-800',
|
StatusBadgeFromOptions,
|
||||||
cancelado: 'bg-red-100 text-red-800',
|
ConfirmDialog,
|
||||||
};
|
Modal,
|
||||||
|
ModalFooter,
|
||||||
const estadoLabels: Record<FraccionamientoEstado, string> = {
|
TextInput,
|
||||||
activo: 'Activo',
|
SelectField,
|
||||||
pausado: 'Pausado',
|
TextareaField,
|
||||||
completado: 'Completado',
|
FormGroup,
|
||||||
cancelado: 'Cancelado',
|
} from '../../../components/common';
|
||||||
};
|
import type { DataTableColumn } from '../../../components/common';
|
||||||
|
import { FRACCIONAMIENTO_ESTADO_OPTIONS, formatDate } from '../../../utils';
|
||||||
|
|
||||||
export function FraccionamientosPage() {
|
export function FraccionamientosPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [estadoFilter, setEstadoFilter] = useState<FraccionamientoEstado | ''>('');
|
const [estadoFilter, setEstadoFilter] = useState<FraccionamientoEstado | ''>('');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState<Fraccionamiento | null>(null);
|
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({
|
const { data, isLoading, error } = useFraccionamientos({
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
@ -44,9 +50,11 @@ export function FraccionamientosPage() {
|
|||||||
const createMutation = useCreateFraccionamiento();
|
const createMutation = useCreateFraccionamiento();
|
||||||
const updateMutation = useUpdateFraccionamiento();
|
const updateMutation = useUpdateFraccionamiento();
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async () => {
|
||||||
await deleteMutation.mutateAsync(id);
|
if (deleteId) {
|
||||||
setDeleteConfirm(null);
|
await deleteMutation.mutateAsync(deleteId);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (formData: CreateFraccionamientoDto) => {
|
const handleSubmit = async (formData: CreateFraccionamientoDto) => {
|
||||||
@ -59,182 +67,111 @@ export function FraccionamientosPage() {
|
|||||||
setEditingItem(null);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<PageHeader
|
||||||
<div>
|
title="Fraccionamientos"
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Fraccionamientos</h1>
|
description="Gestión de fraccionamientos y desarrollos"
|
||||||
<p className="text-gray-600">Gestion de fraccionamientos y desarrollos</p>
|
actions={
|
||||||
</div>
|
<PageHeaderAction onClick={openCreate}>
|
||||||
<button
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
Nuevo Fraccionamiento
|
||||||
onClick={() => {
|
</PageHeaderAction>
|
||||||
setEditingItem(null);
|
}
|
||||||
setShowModal(true);
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5 mr-2" />
|
|
||||||
Nuevo Fraccionamiento
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
<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 flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<SearchInput
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
value={search}
|
||||||
<input
|
onChange={setSearch}
|
||||||
type="text"
|
placeholder="Buscar por nombre o código..."
|
||||||
placeholder="Buscar por nombre o codigo..."
|
className="flex-1"
|
||||||
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}
|
<SelectField
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
options={[{ value: '', label: 'Todos los estados' }, ...filterOptions]}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
value={estadoFilter}
|
value={estadoFilter}
|
||||||
onChange={(e) => setEstadoFilter(e.target.value as FraccionamientoEstado | '')}
|
onChange={(e) => setEstadoFilter(e.target.value as FraccionamientoEstado | '')}
|
||||||
>
|
className="sm:w-48"
|
||||||
<option value="">Todos los estados</option>
|
/>
|
||||||
{Object.entries(estadoLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
<DataTable
|
||||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
data={data?.items || []}
|
||||||
{isLoading ? (
|
columns={columns}
|
||||||
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
isLoading={isLoading}
|
||||||
) : error ? (
|
error={error ? 'Error al cargar los datos' : null}
|
||||||
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
|
emptyState={{ title: 'No hay fraccionamientos', description: 'Crea el primer fraccionamiento para comenzar.' }}
|
||||||
) : 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>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<FraccionamientoModal
|
<FraccionamientoModal
|
||||||
item={editingItem}
|
item={editingItem}
|
||||||
onClose={() => {
|
onClose={() => { setShowModal(false); setEditingItem(null); }}
|
||||||
setShowModal(false);
|
|
||||||
setEditingItem(null);
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
<ConfirmDialog
|
||||||
{deleteConfirm && (
|
isOpen={!!deleteId}
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
onClose={() => setDeleteId(null)}
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
onConfirm={handleDelete}
|
||||||
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
title="Confirmar eliminación"
|
||||||
<p className="text-gray-600 mb-6">
|
message="¿Está seguro de eliminar este fraccionamiento? Esta acción no se puede deshacer."
|
||||||
¿Esta seguro de eliminar este fraccionamiento? Esta accion no se puede deshacer.
|
confirmLabel="Eliminar"
|
||||||
</p>
|
variant="danger"
|
||||||
<div className="flex justify-end gap-3">
|
isLoading={deleteMutation.isPending}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -264,121 +201,84 @@ function FraccionamientoModal({ item, onClose, onSubmit, isLoading }: Fraccionam
|
|||||||
await onSubmit(formData);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal
|
||||||
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
isOpen={true}
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
onClose={onClose}
|
||||||
{item ? 'Editar Fraccionamiento' : 'Nuevo Fraccionamiento'}
|
title={item ? 'Editar Fraccionamiento' : 'Nuevo Fraccionamiento'}
|
||||||
</h3>
|
size="lg"
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
footer={
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<ModalFooter>
|
||||||
<div>
|
<button
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
type="button"
|
||||||
Codigo *
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||||
</label>
|
onClick={onClose}
|
||||||
<input
|
>
|
||||||
type="text"
|
Cancelar
|
||||||
required
|
</button>
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
<button
|
||||||
value={formData.codigo}
|
type="submit"
|
||||||
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
|
form="fraccionamiento-form"
|
||||||
/>
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
</div>
|
disabled={isLoading}
|
||||||
<div>
|
>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||||
Estado
|
</button>
|
||||||
</label>
|
</ModalFooter>
|
||||||
<select
|
}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
>
|
||||||
value={formData.estado}
|
<form id="fraccionamiento-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
onChange={(e) =>
|
<FormGroup cols={2}>
|
||||||
setFormData({ ...formData, estado: e.target.value as FraccionamientoEstado })
|
<TextInput
|
||||||
}
|
label="Código"
|
||||||
>
|
required
|
||||||
{Object.entries(estadoLabels).map(([value, label]) => (
|
value={formData.codigo}
|
||||||
<option key={value} value={value}>
|
onChange={(e) => update('codigo', e.target.value)}
|
||||||
{label}
|
/>
|
||||||
</option>
|
<SelectField
|
||||||
))}
|
label="Estado"
|
||||||
</select>
|
options={estadoOptions}
|
||||||
</div>
|
value={formData.estado || 'activo'}
|
||||||
</div>
|
onChange={(e) => update('estado', e.target.value)}
|
||||||
<div>
|
/>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
</FormGroup>
|
||||||
Nombre *
|
<TextInput
|
||||||
</label>
|
label="Nombre"
|
||||||
<input
|
required
|
||||||
type="text"
|
value={formData.nombre}
|
||||||
required
|
onChange={(e) => update('nombre', e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
/>
|
||||||
value={formData.nombre}
|
<TextareaField
|
||||||
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
label="Descripción"
|
||||||
/>
|
value={formData.descripcion || ''}
|
||||||
</div>
|
onChange={(e) => update('descripcion', e.target.value)}
|
||||||
<div>
|
/>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<TextInput
|
||||||
Descripcion
|
label="Dirección"
|
||||||
</label>
|
value={formData.direccion || ''}
|
||||||
<textarea
|
onChange={(e) => update('direccion', e.target.value)}
|
||||||
rows={3}
|
/>
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
<FormGroup cols={2}>
|
||||||
value={formData.descripcion}
|
<TextInput
|
||||||
onChange={(e) => setFormData({ ...formData, descripcion: e.target.value })}
|
label="Fecha Inicio"
|
||||||
/>
|
type="date"
|
||||||
</div>
|
value={formData.fechaInicio || ''}
|
||||||
<div>
|
onChange={(e) => update('fechaInicio', e.target.value)}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
/>
|
||||||
Direccion
|
<TextInput
|
||||||
</label>
|
label="Fecha Fin Estimada"
|
||||||
<input
|
type="date"
|
||||||
type="text"
|
value={formData.fechaFinEstimada || ''}
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
onChange={(e) => update('fechaFinEstimada', e.target.value)}
|
||||||
value={formData.direccion}
|
/>
|
||||||
onChange={(e) => setFormData({ ...formData, direccion: e.target.value })}
|
</FormGroup>
|
||||||
/>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
web/src/types/api.types.ts
Normal file
50
web/src/types/api.types.ts
Normal 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;
|
||||||
|
}
|
||||||
56
web/src/types/auth.types.ts
Normal file
56
web/src/types/auth.types.ts
Normal 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;
|
||||||
|
}
|
||||||
163
web/src/types/common.types.ts
Normal file
163
web/src/types/common.types.ts
Normal 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>>;
|
||||||
276
web/src/types/construction.types.ts
Normal file
276
web/src/types/construction.types.ts
Normal 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;
|
||||||
|
}
|
||||||
259
web/src/types/estimates.types.ts
Normal file
259
web/src/types/estimates.types.ts
Normal 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
213
web/src/types/hse.types.ts
Normal 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
137
web/src/types/index.ts
Normal 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
221
web/src/utils/constants.ts
Normal 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
215
web/src/utils/formatters.ts
Normal 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)} m²`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
76
web/src/utils/index.ts
Normal 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
215
web/src/utils/validators.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user