erp-construccion-frontend-v2/web/src/pages/admin/bidding/VendorsPage.tsx
Adrian Flores Cortes e4cfe62b1b feat(FASE-5A): Frontend modules Dashboard, Presupuestos, Bidding
New modules implemented:
- Dashboard: EVM visualization, Curva S, KPIs, alerts
- Presupuestos: Conceptos tree, presupuestos list, estimaciones workflow
- Bidding: Opportunities, tenders, proposals, vendors pages

Files created:
- services/reports/ - Reports API service (6 types, 8 methods)
- services/presupuestos/ - Budget/estimates API (presupuestos.api, estimaciones.api)
- services/bidding/ - Bidding API (opportunities, tenders, proposals, vendors)
- hooks/useReports.ts - 8 query hooks, 2 mutation hooks
- hooks/usePresupuestos.ts - 27 hooks for conceptos, presupuestos, estimaciones
- hooks/useBidding.ts - 24 hooks for bidding module
- pages/admin/dashboard/ - DashboardPage with EVM metrics
- pages/admin/presupuestos/ - 3 pages (Conceptos, Presupuestos, Estimaciones)
- pages/admin/bidding/ - 4 pages (Opportunities, Tenders, Proposals, Vendors)

Updated:
- App.tsx: Added routes for new modules
- AdminLayout.tsx: Collapsible sidebar with 4 sections
- hooks/index.ts: Export new hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:21:28 -06:00

371 lines
14 KiB
TypeScript

import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, ToggleLeft, ToggleRight } from 'lucide-react';
import {
useVendors,
useDeleteVendor,
useCreateVendor,
useUpdateVendor,
useToggleVendorActive,
} from '../../../hooks/useBidding';
import {
Vendor,
CreateVendorDto,
} from '../../../services/bidding';
import clsx from 'clsx';
export function VendorsPage() {
const [search, setSearch] = useState('');
const [activeFilter, setActiveFilter] = useState<boolean | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Vendor | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const { data, isLoading, error } = useVendors({
isActive: activeFilter === '' ? undefined : activeFilter,
businessName: search || undefined,
});
const deleteMutation = useDeleteVendor();
const createMutation = useCreateVendor();
const updateMutation = useUpdateVendor();
const toggleActiveMutation = useToggleVendorActive();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const handleSubmit = async (formData: CreateVendorDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
const handleToggleActive = async (id: string) => {
await toggleActiveMutation.mutateAsync(id);
};
const vendors = data?.items || [];
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Proveedores</h1>
<p className="text-gray-600">Gestion de proveedores y subcontratistas</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 Proveedor
</button>
</div>
{/* 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 razon social..."
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={activeFilter === '' ? '' : activeFilter.toString()}
onChange={(e) => {
const val = e.target.value;
setActiveFilter(val === '' ? '' : val === 'true');
}}
>
<option value="">Todos</option>
<option value="true">Activos</option>
<option value="false">Inactivos</option>
</select>
</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>
) : vendors.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay proveedores</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">
Razon Social
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
RFC
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Contacto
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Telefono
</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">
{vendors.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.businessName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.rfc || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.contactName || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.email || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.phone || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleActive(item.id)}
disabled={toggleActiveMutation.isPending}
className={clsx(
'flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium transition-colors',
item.isActive
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
)}
>
{item.isActive ? (
<>
<ToggleRight className="w-4 h-4" />
Activo
</>
) : (
<>
<ToggleLeft className="w-4 h-4" />
Inactivo
</>
)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={() => {
setEditingItem(item);
setShowModal(true);
}}
>
<Pencil className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={() => setDeleteConfirm(item.id)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Modal */}
{showModal && (
<VendorModal
item={editingItem}
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 proveedor? 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>
)}
</div>
);
}
// Modal Component
interface VendorModalProps {
item: Vendor | null;
onClose: () => void;
onSubmit: (data: CreateVendorDto) => Promise<void>;
isLoading: boolean;
}
function VendorModal({ item, onClose, onSubmit, isLoading }: VendorModalProps) {
const [formData, setFormData] = useState<CreateVendorDto>({
businessName: item?.businessName || '',
rfc: item?.rfc || '',
contactName: item?.contactName || '',
email: item?.email || '',
phone: item?.phone || '',
isActive: item?.isActive ?? true,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
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 Proveedor' : 'Nuevo Proveedor'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Razon Social *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.businessName}
onChange={(e) => setFormData({ ...formData, businessName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
RFC
</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="XAXX010101000"
value={formData.rfc}
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre de Contacto
</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.contactName}
onChange={(e) => setFormData({ ...formData, contactName: 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">
Email
</label>
<input
type="email"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefono
</label>
<input
type="tel"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
/>
<label htmlFor="isActive" className="ml-2 block text-sm text-gray-900">
Proveedor activo
</label>
</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>
);
}