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