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>
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
import { useState } from 'react';
|
|
import { Plus, Pencil, Trash2, Search, Send } from 'lucide-react';
|
|
import {
|
|
useProposals,
|
|
useDeleteProposal,
|
|
useCreateProposal,
|
|
useUpdateProposal,
|
|
useSubmitProposal,
|
|
useTenders,
|
|
} from '../../../hooks/useBidding';
|
|
import {
|
|
Proposal,
|
|
ProposalStatus,
|
|
CreateProposalDto,
|
|
} from '../../../services/bidding';
|
|
import clsx from 'clsx';
|
|
|
|
const statusColors: Record<ProposalStatus, string> = {
|
|
draft: 'bg-gray-100 text-gray-800',
|
|
submitted: 'bg-blue-100 text-blue-800',
|
|
under_review: 'bg-yellow-100 text-yellow-800',
|
|
accepted: 'bg-green-100 text-green-800',
|
|
rejected: 'bg-red-100 text-red-800',
|
|
};
|
|
|
|
const statusLabels: Record<ProposalStatus, string> = {
|
|
draft: 'Borrador',
|
|
submitted: 'Enviada',
|
|
under_review: 'En Revision',
|
|
accepted: 'Aceptada',
|
|
rejected: 'Rechazada',
|
|
};
|
|
|
|
const allStatuses: ProposalStatus[] = ['draft', 'submitted', 'under_review', 'accepted', 'rejected'];
|
|
|
|
export function ProposalsPage() {
|
|
const [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<ProposalStatus | ''>('');
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<Proposal | null>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
const [submitConfirm, setSubmitConfirm] = useState<string | null>(null);
|
|
|
|
const { data, isLoading, error } = useProposals({
|
|
status: statusFilter || undefined,
|
|
});
|
|
|
|
const deleteMutation = useDeleteProposal();
|
|
const createMutation = useCreateProposal();
|
|
const updateMutation = useUpdateProposal();
|
|
const submitMutation = useSubmitProposal();
|
|
|
|
const handleDelete = async (id: string) => {
|
|
await deleteMutation.mutateAsync(id);
|
|
setDeleteConfirm(null);
|
|
};
|
|
|
|
const handleSubmit = async (formData: CreateProposalDto) => {
|
|
if (editingItem) {
|
|
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
|
} else {
|
|
await createMutation.mutateAsync(formData);
|
|
}
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
};
|
|
|
|
const handleSubmitProposal = async (id: string) => {
|
|
await submitMutation.mutateAsync(id);
|
|
setSubmitConfirm(null);
|
|
};
|
|
|
|
const proposals = data?.items || [];
|
|
const filteredProposals = search
|
|
? proposals.filter((p) =>
|
|
p.proposalNumber.toLowerCase().includes(search.toLowerCase()) ||
|
|
p.tender?.title.toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
: proposals;
|
|
|
|
const formatCurrency = (value: number) => {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
style: 'currency',
|
|
currency: 'MXN',
|
|
}).format(value);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Propuestas</h1>
|
|
<p className="text-gray-600">Gestion de propuestas tecnicas y economicas</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" />
|
|
Nueva Propuesta
|
|
</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 numero o licitacion..."
|
|
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={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as ProposalStatus | '')}
|
|
>
|
|
<option value="">Todos los estados</option>
|
|
{allStatuses.map((status) => (
|
|
<option key={status} value={status}>
|
|
{statusLabels[status]}
|
|
</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>
|
|
) : filteredProposals.length === 0 ? (
|
|
<div className="p-8 text-center text-gray-500">No hay propuestas</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">
|
|
No. Propuesta
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
Licitacion
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
Monto Total
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
Punt. Tecnico
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
Punt. Economico
|
|
</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">
|
|
{filteredProposals.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.proposalNumber}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 max-w-xs truncate">
|
|
{item.tender?.title || '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{formatCurrency(item.totalAmount)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{item.technicalScore !== undefined ? `${item.technicalScore} pts` : '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{item.economicScore !== undefined ? `${item.economicScore} pts` : '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={clsx(
|
|
'px-2 py-1 text-xs font-medium rounded-full',
|
|
statusColors[item.status]
|
|
)}
|
|
>
|
|
{statusLabels[item.status]}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{item.status === 'draft' && (
|
|
<button
|
|
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
|
title="Enviar propuesta"
|
|
onClick={() => setSubmitConfirm(item.id)}
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
<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 && (
|
|
<ProposalModal
|
|
item={editingItem}
|
|
onClose={() => {
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
}}
|
|
onSubmit={handleSubmit}
|
|
isLoading={createMutation.isPending || updateMutation.isPending}
|
|
/>
|
|
)}
|
|
|
|
{/* Submit Confirmation */}
|
|
{submitConfirm && (
|
|
<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 envio</h3>
|
|
<p className="text-gray-600 mb-6">
|
|
¿Esta seguro de enviar esta propuesta? Una vez enviada no podra modificarse.
|
|
</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={() => setSubmitConfirm(null)}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
|
onClick={() => handleSubmitProposal(submitConfirm)}
|
|
disabled={submitMutation.isPending}
|
|
>
|
|
{submitMutation.isPending ? 'Enviando...' : 'Enviar Propuesta'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 esta propuesta? 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 ProposalModalProps {
|
|
item: Proposal | null;
|
|
onClose: () => void;
|
|
onSubmit: (data: CreateProposalDto) => Promise<void>;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
function ProposalModal({ item, onClose, onSubmit, isLoading }: ProposalModalProps) {
|
|
const { data: tendersData } = useTenders({ status: 'published' });
|
|
const tenders = tendersData?.items || [];
|
|
|
|
const [formData, setFormData] = useState<CreateProposalDto>({
|
|
tenderId: item?.tenderId || '',
|
|
proposalNumber: item?.proposalNumber || '',
|
|
totalAmount: item?.totalAmount || 0,
|
|
technicalScore: item?.technicalScore,
|
|
economicScore: item?.economicScore,
|
|
status: item?.status || 'draft',
|
|
});
|
|
|
|
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 Propuesta' : 'Nueva Propuesta'}
|
|
</h3>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Licitacion *
|
|
</label>
|
|
<select
|
|
required
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
value={formData.tenderId}
|
|
onChange={(e) => setFormData({ ...formData, tenderId: e.target.value })}
|
|
disabled={!!item}
|
|
>
|
|
<option value="">Seleccionar licitacion</option>
|
|
{tenders.map((tender) => (
|
|
<option key={tender.id} value={tender.id}>
|
|
{tender.referenceNumber} - {tender.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
No. Propuesta *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
value={formData.proposalNumber}
|
|
onChange={(e) => setFormData({ ...formData, proposalNumber: 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.status}
|
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProposalStatus })}
|
|
>
|
|
{allStatuses.map((status) => (
|
|
<option key={status} value={status}>
|
|
{statusLabels[status]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Monto Total *
|
|
</label>
|
|
<input
|
|
type="number"
|
|
required
|
|
min="0"
|
|
step="0.01"
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
value={formData.totalAmount}
|
|
onChange={(e) => setFormData({ ...formData, totalAmount: parseFloat(e.target.value) || 0 })}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Puntaje Tecnico
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.01"
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
value={formData.technicalScore || ''}
|
|
onChange={(e) => setFormData({ ...formData, technicalScore: e.target.value ? parseFloat(e.target.value) : undefined })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Puntaje Economico
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.01"
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
value={formData.economicScore || ''}
|
|
onChange={(e) => setFormData({ ...formData, economicScore: e.target.value ? parseFloat(e.target.value) : undefined })}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|