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>
338 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|