erp-construccion-frontend-v2/web/src/pages/admin/contratos/ContratosPage.tsx
Adrian Flores Cortes 55261598a2 [FIX] fix: Resolve TypeScript errors for successful build
Fixes:
- Add teal, cyan, slate colors to StatusColor type and StatusBadge
- Create StatsCard component with color prop for backward compatibility
- Add label/required props to FormGroup component
- Fix Pagination to accept both currentPage and page props
- Fix unused imports in quality and contracts pages
- Add missing Plus, Trash2, User icon imports in contracts pages
- Remove duplicate formatDate function in ContratoDetailPage

New components:
- StatsCard, StatsCardGrid for statistics display

Build: Success (npm run build passes)
Dev: Success (npm run dev starts on port 3020)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:36:21 -06:00

338 lines
13 KiB
TypeScript

/**
* ContratosPage - Lista de contratos
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, Pencil, Trash2, FileText, Building2, Calendar, DollarSign } from 'lucide-react';
import {
useContracts,
useDeleteContract,
} from '../../../hooks/useContracts';
import { useSubcontractors } from '../../../hooks/useContracts';
import type {
Contract,
ContractType,
ContractStatus,
ContractFilters,
} from '../../../types/contracts.types';
import {
CONTRACT_TYPE_OPTIONS,
CONTRACT_STATUS_OPTIONS,
} from '../../../types/contracts.types';
import {
PageHeader,
PageHeaderAction,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
EmptyState,
Pagination,
} from '../../../components/common';
import { ContractForm } from '../../../components/contracts/ContractForm';
export function ContratosPage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<ContractType | ''>('');
const [statusFilter, setStatusFilter] = useState<ContractStatus | ''>('');
const [subcontractorFilter, setSubcontractorFilter] = useState('');
const [page, setPage] = useState(1);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Contract | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const filters: ContractFilters = {
search: search || undefined,
contractType: typeFilter || undefined,
status: statusFilter || undefined,
subcontractorId: subcontractorFilter || undefined,
page,
limit: 10,
};
const { data, isLoading, error } = useContracts(filters);
const { data: subcontractorsData } = useSubcontractors({ status: 'active' });
const deleteMutation = useDeleteContract();
const contracts = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 10);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleView = (id: string) => {
navigate(`/admin/contratos/${id}`);
};
const handleEdit = (contract: Contract) => {
setEditingItem(contract);
setShowModal(true);
};
const handleCreate = () => {
setEditingItem(null);
setShowModal(true);
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (isLoading) {
return <LoadingOverlay message="Cargando contratos..." />;
}
if (error) {
return (
<EmptyState
title="Error al cargar"
description="No se pudieron cargar los contratos. Intente de nuevo."
/>
);
}
return (
<div>
<PageHeader
title="Contratos"
description="Gestion de contratos con clientes y subcontratistas"
actions={
<PageHeaderAction onClick={handleCreate}>
<Plus className="w-5 h-5 mr-2" />
Nuevo Contrato
</PageHeaderAction>
}
/>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por numero o nombre..."
/>
<SelectField
options={[
{ value: '', label: 'Todos los tipos' },
...CONTRACT_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as ContractType | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todos los estados' },
...CONTRACT_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ContractStatus | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todos los subcontratistas' },
...(subcontractorsData?.items || []).map(s => ({
value: s.id,
label: s.businessName,
})),
]}
value={subcontractorFilter}
onChange={(e) => setSubcontractorFilter(e.target.value)}
/>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
{contracts.length === 0 ? (
<EmptyState
icon={<FileText className="w-12 h-12 text-gray-400" />}
title="No hay contratos"
description="Crea el primer contrato para comenzar."
/>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Contrato
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Tipo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Cliente/Subcontratista
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Vigencia
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Monto
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Avance
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{contracts.map((contract) => (
<tr key={contract.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<FileText className="w-5 h-5 text-gray-400 mr-3" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{contract.contractNumber}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{contract.name}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={contract.contractType}
options={[...CONTRACT_TYPE_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Building2 className="w-4 h-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-900 dark:text-gray-100">
{contract.contractType === 'client'
? contract.clientName
: contract.subcontractor?.businessName || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<Calendar className="w-4 h-4 mr-1" />
{formatDate(contract.startDate)} - {formatDate(contract.endDate)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end">
<DollarSign className="w-4 h-4 text-gray-400 mr-1" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(contract.contractAmount)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center">
<div className="w-16 bg-gray-200 dark:bg-gray-600 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${contract.progressPercentage}%` }}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{contract.progressPercentage}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<StatusBadgeFromOptions
value={contract.status}
options={[...CONTRACT_STATUS_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleView(contract.id)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(contract)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteId(contract.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</div>
{/* Modal */}
{showModal && (
<ContractForm
contract={editingItem}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
/>
)}
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Confirmar eliminacion"
message="Esta seguro de eliminar este contrato? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
</div>
);
}