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>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 07:21:28 -06:00
parent f3d91433fe
commit e4cfe62b1b
24 changed files with 6308 additions and 37 deletions

View File

@ -13,18 +13,26 @@ import {
PrototiposPage,
} from './pages/admin/proyectos';
import { ManzanasPage } from './pages/admin/proyectos/ManzanasPage';
import { DashboardPage } from './pages/admin/dashboard';
import { ConceptosPage, PresupuestosPage, EstimacionesPage } from './pages/admin/presupuestos';
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
function App() {
return (
<BrowserRouter>
<div className="app">
<Routes>
{/* Ruta principal - redirect to admin */}
<Route path="/" element={<Navigate to="/admin/proyectos/fraccionamientos" replace />} />
{/* Ruta principal - redirect to admin dashboard */}
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
{/* Portal Admin */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="proyectos/fraccionamientos" replace />} />
<Route index element={<Navigate to="dashboard" replace />} />
{/* Dashboard */}
<Route path="dashboard" element={<DashboardPage />} />
{/* Proyectos */}
<Route path="proyectos">
<Route index element={<Navigate to="fraccionamientos" replace />} />
<Route path="fraccionamientos" element={<FraccionamientosPage />} />
@ -34,6 +42,23 @@ function App() {
<Route path="lotes" element={<LotesPage />} />
<Route path="prototipos" element={<PrototiposPage />} />
</Route>
{/* Presupuestos */}
<Route path="presupuestos">
<Route index element={<Navigate to="catalogo" replace />} />
<Route path="catalogo" element={<ConceptosPage />} />
<Route path="presupuestos" element={<PresupuestosPage />} />
<Route path="estimaciones" element={<EstimacionesPage />} />
</Route>
{/* Licitaciones */}
<Route path="licitaciones">
<Route index element={<Navigate to="oportunidades" replace />} />
<Route path="oportunidades" element={<OpportunitiesPage />} />
<Route path="concursos" element={<TendersPage />} />
<Route path="propuestas" element={<ProposalsPage />} />
<Route path="proveedores" element={<VendorsPage />} />
</Route>
</Route>
{/* Portal Supervisor */}

View File

@ -1 +1,4 @@
export * from './useConstruccion';
export * from './usePresupuestos';
export * from './useReports';
export * from './useBidding';

343
web/src/hooks/useBidding.ts Normal file
View File

@ -0,0 +1,343 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
import { ApiError } from '../services/api';
import {
opportunitiesApi,
tendersApi,
proposalsApi,
vendorsApi,
OpportunityFilters,
TenderFilters,
ProposalFilters,
VendorFilters,
OpportunityStatus,
TenderStatus,
CreateOpportunityDto,
UpdateOpportunityDto,
CreateTenderDto,
UpdateTenderDto,
CreateProposalDto,
UpdateProposalDto,
CreateVendorDto,
UpdateVendorDto,
} from '../services/bidding';
// ============================================================================
// QUERY KEYS
// ============================================================================
export const biddingKeys = {
opportunities: {
all: ['bidding', 'opportunities'] as const,
list: (filters?: OpportunityFilters) =>
[...biddingKeys.opportunities.all, 'list', filters] as const,
detail: (id: string) => [...biddingKeys.opportunities.all, 'detail', id] as const,
},
tenders: {
all: ['bidding', 'tenders'] as const,
list: (filters?: TenderFilters) => [...biddingKeys.tenders.all, 'list', filters] as const,
detail: (id: string) => [...biddingKeys.tenders.all, 'detail', id] as const,
},
proposals: {
all: ['bidding', 'proposals'] as const,
list: (filters?: ProposalFilters) => [...biddingKeys.proposals.all, 'list', filters] as const,
detail: (id: string) => [...biddingKeys.proposals.all, 'detail', id] as const,
},
vendors: {
all: ['bidding', 'vendors'] as const,
list: (filters?: VendorFilters) => [...biddingKeys.vendors.all, 'list', filters] as const,
detail: (id: string) => [...biddingKeys.vendors.all, 'detail', id] as const,
},
};
// ============================================================================
// ERROR HANDLER
// ============================================================================
const handleError = (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Ha ocurrido un error';
toast.error(message);
};
// ============================================================================
// OPPORTUNITIES HOOKS
// ============================================================================
export function useOpportunities(filters?: OpportunityFilters) {
return useQuery({
queryKey: biddingKeys.opportunities.list(filters),
queryFn: () => opportunitiesApi.list(filters),
});
}
export function useOpportunity(id: string) {
return useQuery({
queryKey: biddingKeys.opportunities.detail(id),
queryFn: () => opportunitiesApi.get(id),
enabled: !!id,
});
}
export function useCreateOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateOpportunityDto) => opportunitiesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
toast.success('Oportunidad creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateOpportunityDto }) =>
opportunitiesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.detail(id) });
toast.success('Oportunidad actualizada');
},
onError: handleError,
});
}
export function useDeleteOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => opportunitiesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
toast.success('Oportunidad eliminada');
},
onError: handleError,
});
}
export function useUpdateOpportunityStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: OpportunityStatus }) =>
opportunitiesApi.updateStatus(id, status),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.detail(id) });
toast.success('Estado de oportunidad actualizado');
},
onError: handleError,
});
}
// ============================================================================
// TENDERS HOOKS
// ============================================================================
export function useTenders(filters?: TenderFilters) {
return useQuery({
queryKey: biddingKeys.tenders.list(filters),
queryFn: () => tendersApi.list(filters),
});
}
export function useTender(id: string) {
return useQuery({
queryKey: biddingKeys.tenders.detail(id),
queryFn: () => tendersApi.get(id),
enabled: !!id,
});
}
export function useCreateTender() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTenderDto) => tendersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
toast.success('Licitacion creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateTender() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTenderDto }) =>
tendersApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.detail(id) });
toast.success('Licitacion actualizada');
},
onError: handleError,
});
}
export function useDeleteTender() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => tendersApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
toast.success('Licitacion eliminada');
},
onError: handleError,
});
}
export function useUpdateTenderStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: TenderStatus }) =>
tendersApi.updateStatus(id, status),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.detail(id) });
toast.success('Estado de licitacion actualizado');
},
onError: handleError,
});
}
// ============================================================================
// PROPOSALS HOOKS
// ============================================================================
export function useProposals(filters?: ProposalFilters) {
return useQuery({
queryKey: biddingKeys.proposals.list(filters),
queryFn: () => proposalsApi.list(filters),
});
}
export function useProposal(id: string) {
return useQuery({
queryKey: biddingKeys.proposals.detail(id),
queryFn: () => proposalsApi.get(id),
enabled: !!id,
});
}
export function useCreateProposal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProposalDto) => proposalsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
toast.success('Propuesta creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateProposal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProposalDto }) =>
proposalsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.detail(id) });
toast.success('Propuesta actualizada');
},
onError: handleError,
});
}
export function useDeleteProposal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => proposalsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
toast.success('Propuesta eliminada');
},
onError: handleError,
});
}
export function useSubmitProposal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => proposalsApi.submit(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.detail(id) });
toast.success('Propuesta enviada exitosamente');
},
onError: handleError,
});
}
// ============================================================================
// VENDORS HOOKS
// ============================================================================
export function useVendors(filters?: VendorFilters) {
return useQuery({
queryKey: biddingKeys.vendors.list(filters),
queryFn: () => vendorsApi.list(filters),
});
}
export function useVendor(id: string) {
return useQuery({
queryKey: biddingKeys.vendors.detail(id),
queryFn: () => vendorsApi.get(id),
enabled: !!id,
});
}
export function useCreateVendor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateVendorDto) => vendorsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
toast.success('Proveedor creado exitosamente');
},
onError: handleError,
});
}
export function useUpdateVendor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateVendorDto }) =>
vendorsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.detail(id) });
toast.success('Proveedor actualizado');
},
onError: handleError,
});
}
export function useDeleteVendor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => vendorsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
toast.success('Proveedor eliminado');
},
onError: handleError,
});
}
export function useToggleVendorActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => vendorsApi.toggleActive(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.detail(id) });
toast.success('Estado del proveedor actualizado');
},
onError: handleError,
});
}

View File

@ -0,0 +1,430 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
import { ApiError } from '../services/api';
import {
conceptosApi,
ConceptoFilters,
CreateConceptoDto,
UpdateConceptoDto,
presupuestosApi,
PresupuestoFilters,
CreatePresupuestoDto,
UpdatePresupuestoDto,
estimacionesApi,
EstimacionFilters,
CreateEstimacionDto,
UpdateEstimacionDto,
CreateEstimacionPartidaDto,
UpdateEstimacionPartidaDto,
CreateGeneradorDto,
UpdateGeneradorDto,
} from '../services/presupuestos';
// ==================== QUERY KEYS ====================
export const presupuestosKeys = {
conceptos: {
all: ['presupuestos', 'conceptos'] as const,
list: (filters?: ConceptoFilters) => [...presupuestosKeys.conceptos.all, 'list', filters] as const,
detail: (id: string) => [...presupuestosKeys.conceptos.all, 'detail', id] as const,
tree: (rootId?: string) => [...presupuestosKeys.conceptos.all, 'tree', rootId] as const,
},
presupuestos: {
all: ['presupuestos', 'presupuestos'] as const,
list: (filters?: PresupuestoFilters) => [...presupuestosKeys.presupuestos.all, 'list', filters] as const,
detail: (id: string) => [...presupuestosKeys.presupuestos.all, 'detail', id] as const,
},
estimaciones: {
all: ['presupuestos', 'estimaciones'] as const,
list: (filters?: EstimacionFilters) => [...presupuestosKeys.estimaciones.all, 'list', filters] as const,
detail: (id: string) => [...presupuestosKeys.estimaciones.all, 'detail', id] as const,
},
};
// ==================== ERROR HANDLER ====================
const handleError = (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Ha ocurrido un error';
toast.error(message);
};
// ==================== CONCEPTOS ====================
export function useConceptos(filters?: ConceptoFilters) {
return useQuery({
queryKey: presupuestosKeys.conceptos.list(filters),
queryFn: () => conceptosApi.list(filters),
});
}
export function useConcepto(id: string) {
return useQuery({
queryKey: presupuestosKeys.conceptos.detail(id),
queryFn: () => conceptosApi.get(id),
enabled: !!id,
});
}
export function useConceptosTree(rootId?: string) {
return useQuery({
queryKey: presupuestosKeys.conceptos.tree(rootId),
queryFn: () => conceptosApi.getTree(rootId),
});
}
export function useCreateConcepto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateConceptoDto) => conceptosApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.all });
toast.success('Concepto creado exitosamente');
},
onError: handleError,
});
}
export function useUpdateConcepto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateConceptoDto }) =>
conceptosApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.detail(id) });
toast.success('Concepto actualizado');
},
onError: handleError,
});
}
export function useDeleteConcepto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => conceptosApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.all });
toast.success('Concepto eliminado');
},
onError: handleError,
});
}
// ==================== PRESUPUESTOS ====================
export function usePresupuestos(filters?: PresupuestoFilters) {
return useQuery({
queryKey: presupuestosKeys.presupuestos.list(filters),
queryFn: () => presupuestosApi.list(filters),
});
}
export function usePresupuesto(id: string) {
return useQuery({
queryKey: presupuestosKeys.presupuestos.detail(id),
queryFn: () => presupuestosApi.get(id),
enabled: !!id,
});
}
export function useCreatePresupuesto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePresupuestoDto) => presupuestosApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
toast.success('Presupuesto creado exitosamente');
},
onError: handleError,
});
}
export function useUpdatePresupuesto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdatePresupuestoDto }) =>
presupuestosApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(id) });
toast.success('Presupuesto actualizado');
},
onError: handleError,
});
}
export function useDeletePresupuesto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => presupuestosApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
toast.success('Presupuesto eliminado');
},
onError: handleError,
});
}
export function useApprovePresupuesto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => presupuestosApi.approve(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(id) });
toast.success('Presupuesto aprobado');
},
onError: handleError,
});
}
export function useDuplicatePresupuesto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => presupuestosApi.duplicate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
toast.success('Presupuesto duplicado exitosamente');
},
onError: handleError,
});
}
// ==================== ESTIMACIONES ====================
export function useEstimaciones(filters?: EstimacionFilters) {
return useQuery({
queryKey: presupuestosKeys.estimaciones.list(filters),
queryFn: () => estimacionesApi.list(filters),
});
}
export function useEstimacion(id: string) {
return useQuery({
queryKey: presupuestosKeys.estimaciones.detail(id),
queryFn: () => estimacionesApi.get(id),
enabled: !!id,
});
}
export function useCreateEstimacion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateEstimacionDto) => estimacionesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
toast.success('Estimacion creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateEstimacion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateEstimacionDto }) =>
estimacionesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
toast.success('Estimacion actualizada');
},
onError: handleError,
});
}
export function useDeleteEstimacion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => estimacionesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
toast.success('Estimacion eliminada');
},
onError: handleError,
});
}
// ==================== ESTIMACIONES WORKFLOW ====================
export function useSubmitEstimacion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => estimacionesApi.submit(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
toast.success('Estimacion enviada a revision');
},
onError: handleError,
});
}
export function useApproveEstimacion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, montoAprobado }: { id: string; montoAprobado?: number }) =>
estimacionesApi.approve(id, montoAprobado),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
toast.success('Estimacion aprobada');
},
onError: handleError,
});
}
export function useRejectEstimacion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, motivo }: { id: string; motivo: string }) =>
estimacionesApi.reject(id, motivo),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
toast.success('Estimacion rechazada');
},
onError: handleError,
});
}
export function useMarkEstimacionInvoiced() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, montoFacturado }: { id: string; montoFacturado: number }) =>
estimacionesApi.markAsInvoiced(id, montoFacturado),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
toast.success('Estimacion marcada como facturada');
},
onError: handleError,
});
}
export function useMarkEstimacionPaid() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, montoCobrado }: { id: string; montoCobrado: number }) =>
estimacionesApi.markAsPaid(id, montoCobrado),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
toast.success('Estimacion marcada como cobrada');
},
onError: handleError,
});
}
// ==================== ESTIMACIONES PARTIDAS ====================
export function useAddEstimacionPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ estimacionId, data }: { estimacionId: string; data: CreateEstimacionPartidaDto }) =>
estimacionesApi.addPartida(estimacionId, data),
onSuccess: (_, { estimacionId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
toast.success('Partida agregada a la estimacion');
},
onError: handleError,
});
}
export function useUpdateEstimacionPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
estimacionId,
partidaId,
data,
}: {
estimacionId: string;
partidaId: string;
data: UpdateEstimacionPartidaDto;
}) => estimacionesApi.updatePartida(estimacionId, partidaId, data),
onSuccess: (_, { estimacionId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
toast.success('Partida actualizada');
},
onError: handleError,
});
}
export function useRemoveEstimacionPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ estimacionId, partidaId }: { estimacionId: string; partidaId: string }) =>
estimacionesApi.removePartida(estimacionId, partidaId),
onSuccess: (_, { estimacionId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
toast.success('Partida eliminada de la estimacion');
},
onError: handleError,
});
}
// ==================== ESTIMACIONES GENERADORES ====================
export function useAddGenerador() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
estimacionId,
partidaId,
data,
}: {
estimacionId: string;
partidaId: string;
data: CreateGeneradorDto;
}) => estimacionesApi.addGenerador(estimacionId, partidaId, data),
onSuccess: (_, { estimacionId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
toast.success('Generador agregado');
},
onError: handleError,
});
}
export function useUpdateGenerador() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
estimacionId,
partidaId,
generadorId,
data,
}: {
estimacionId: string;
partidaId: string;
generadorId: string;
data: UpdateGeneradorDto;
}) => estimacionesApi.updateGenerador(estimacionId, partidaId, generadorId, data),
onSuccess: (_, { estimacionId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
toast.success('Generador actualizado');
},
onError: handleError,
});
}
export function useRemoveGenerador() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
estimacionId,
partidaId,
generadorId,
}: {
estimacionId: string;
partidaId: string;
generadorId: string;
}) => estimacionesApi.removeGenerador(estimacionId, partidaId, generadorId),
onSuccess: (_, { estimacionId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
toast.success('Generador eliminado');
},
onError: handleError,
});
}

154
web/src/hooks/useReports.ts Normal file
View File

@ -0,0 +1,154 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
import { ApiError } from '../services/api';
import {
reportsApi,
DateRangeParams,
ProjectSummaryFilters,
AlertFilters,
} from '../services/reports';
// =============================================================================
// Query Keys Factory
// =============================================================================
export const reportsKeys = {
all: ['reports'] as const,
earnedValue: (projectId: string, params?: DateRangeParams) =>
[...reportsKeys.all, 'earnedValue', projectId, params] as const,
sCurve: (projectId: string, params?: DateRangeParams) =>
[...reportsKeys.all, 'sCurve', projectId, params] as const,
projectsSummary: (filters?: ProjectSummaryFilters) =>
[...reportsKeys.all, 'projectsSummary', filters] as const,
dashboardStats: () => [...reportsKeys.all, 'dashboardStats'] as const,
projectKPIs: (projectId: string, params?: DateRangeParams) =>
[...reportsKeys.all, 'projectKPIs', projectId, params] as const,
alerts: (filters?: AlertFilters) => [...reportsKeys.all, 'alerts', filters] as const,
};
// =============================================================================
// Error Handler
// =============================================================================
const handleError = (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Ha ocurrido un error';
toast.error(message);
};
// =============================================================================
// Query Hooks - Earned Value Management
// =============================================================================
/**
* Get Earned Value metrics for a specific project
* Returns SPI, CPI, EV, PV, AC, and other EVM indicators
*/
export function useEarnedValue(projectId: string, params?: DateRangeParams) {
return useQuery({
queryKey: reportsKeys.earnedValue(projectId, params),
queryFn: () => reportsApi.getEarnedValue(projectId, params),
enabled: !!projectId,
});
}
/**
* Get S-Curve data for a specific project
* Returns time series data for planned vs actual progress
*/
export function useSCurveData(projectId: string, params?: DateRangeParams) {
return useQuery({
queryKey: reportsKeys.sCurve(projectId, params),
queryFn: () => reportsApi.getSCurveData(projectId, params),
enabled: !!projectId,
});
}
// =============================================================================
// Query Hooks - Dashboard
// =============================================================================
/**
* Get summary of all projects with their KPIs
* Supports filtering by status and search
*/
export function useProjectsSummary(filters?: ProjectSummaryFilters) {
return useQuery({
queryKey: reportsKeys.projectsSummary(filters),
queryFn: () => reportsApi.getProjectsSummary(filters),
});
}
/**
* Get general dashboard statistics
* Returns aggregate metrics for all projects
*/
export function useDashboardStats() {
return useQuery({
queryKey: reportsKeys.dashboardStats(),
queryFn: () => reportsApi.getDashboardStats(),
});
}
/**
* Get KPIs for a specific project
*/
export function useProjectKPIs(projectId: string, params?: DateRangeParams) {
return useQuery({
queryKey: reportsKeys.projectKPIs(projectId, params),
queryFn: () => reportsApi.getProjectKPIs(projectId, params),
enabled: !!projectId,
});
}
// =============================================================================
// Query Hooks - Alerts
// =============================================================================
/**
* Get active alerts
* Supports filtering by severity, project, and type
*/
export function useAlerts(filters?: AlertFilters) {
return useQuery({
queryKey: reportsKeys.alerts(filters),
queryFn: () => reportsApi.getAlerts(filters),
});
}
// =============================================================================
// Mutation Hooks - Alerts
// =============================================================================
/**
* Acknowledge an alert
* Marks the alert as seen without resolving it
*/
export function useAcknowledgeAlert() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (alertId: string) => reportsApi.acknowledgeAlert(alertId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reportsKeys.all });
toast.success('Alerta reconocida');
},
onError: handleError,
});
}
/**
* Resolve an alert
* Marks the alert as resolved
*/
export function useResolveAlert() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (alertId: string) => reportsApi.resolveAlert(alertId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reportsKeys.all });
queryClient.invalidateQueries({ queryKey: reportsKeys.dashboardStats() });
toast.success('Alerta resuelta');
},
onError: handleError,
});
}

View File

@ -11,6 +11,15 @@ import {
LogOut,
User,
ChevronDown,
ChevronRight,
LayoutDashboard,
Calculator,
FileText,
Receipt,
Target,
FileCheck,
Send,
Users,
} from 'lucide-react';
import clsx from 'clsx';
import { useAuthStore } from '../stores/authStore';
@ -21,17 +30,62 @@ interface NavItem {
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{ label: 'Fraccionamientos', href: '/admin/proyectos/fraccionamientos', icon: Building2 },
{ label: 'Etapas', href: '/admin/proyectos/etapas', icon: Layers },
{ label: 'Manzanas', href: '/admin/proyectos/manzanas', icon: LayoutGrid },
{ label: 'Lotes', href: '/admin/proyectos/lotes', icon: Map },
{ label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home },
interface NavSection {
title: string;
items: NavItem[];
defaultOpen?: boolean;
}
const navSections: NavSection[] = [
{
title: 'General',
defaultOpen: true,
items: [
{ label: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
],
},
{
title: 'Proyectos',
defaultOpen: true,
items: [
{ label: 'Fraccionamientos', href: '/admin/proyectos/fraccionamientos', icon: Building2 },
{ label: 'Etapas', href: '/admin/proyectos/etapas', icon: Layers },
{ label: 'Manzanas', href: '/admin/proyectos/manzanas', icon: LayoutGrid },
{ label: 'Lotes', href: '/admin/proyectos/lotes', icon: Map },
{ label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home },
],
},
{
title: 'Presupuestos',
defaultOpen: false,
items: [
{ label: 'Catalogo Conceptos', href: '/admin/presupuestos/catalogo', icon: Calculator },
{ label: 'Presupuestos', href: '/admin/presupuestos/presupuestos', icon: FileText },
{ label: 'Estimaciones', href: '/admin/presupuestos/estimaciones', icon: Receipt },
],
},
{
title: 'Licitaciones',
defaultOpen: false,
items: [
{ label: 'Oportunidades', href: '/admin/licitaciones/oportunidades', icon: Target },
{ label: 'Concursos', href: '/admin/licitaciones/concursos', icon: FileCheck },
{ label: 'Propuestas', href: '/admin/licitaciones/propuestas', icon: Send },
{ label: 'Proveedores', href: '/admin/licitaciones/proveedores', icon: Users },
],
},
];
export function AdminLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
navSections.forEach(section => {
initial[section.title] = section.defaultOpen ?? false;
});
return initial;
});
const location = useLocation();
const { user, logout } = useAuthStore();
@ -40,6 +94,17 @@ export function AdminLayout() {
window.location.href = '/auth/login';
};
const toggleSection = (title: string) => {
setExpandedSections(prev => ({
...prev,
[title]: !prev[title],
}));
};
const isSectionActive = (section: NavSection) => {
return section.items.some(item => location.pathname.startsWith(item.href));
};
return (
<div className="min-h-screen bg-gray-100">
{/* Mobile sidebar backdrop */}
@ -70,34 +135,55 @@ export function AdminLayout() {
</button>
</div>
<nav className="px-4 py-6">
<div className="mb-4">
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Proyectos
</h3>
</div>
<ul className="space-y-1">
{navItems.map((item) => {
const isActive = location.pathname === item.href;
const Icon = item.icon;
return (
<li key={item.href}>
<Link
to={item.href}
className={clsx(
'flex items-center px-3 py-2 rounded-lg transition-colors',
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
)}
>
<Icon className="w-5 h-5 mr-3" />
{item.label}
</Link>
</li>
);
})}
</ul>
<nav className="px-4 py-4 overflow-y-auto h-[calc(100vh-4rem)]">
{navSections.map((section) => {
const isExpanded = expandedSections[section.title];
const isActive = isSectionActive(section);
return (
<div key={section.title} className="mb-2">
<button
onClick={() => toggleSection(section.title)}
className={clsx(
'flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider rounded-lg transition-colors',
isActive ? 'text-blue-700 bg-blue-50' : 'text-gray-500 hover:bg-gray-50'
)}
>
{section.title}
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{isExpanded && (
<ul className="mt-1 space-y-1">
{section.items.map((item) => {
const isItemActive = location.pathname === item.href;
const Icon = item.icon;
return (
<li key={item.href}>
<Link
to={item.href}
className={clsx(
'flex items-center px-3 py-2 rounded-lg transition-colors text-sm',
isItemActive
? 'bg-blue-100 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-100'
)}
>
<Icon className="w-4 h-4 mr-3" />
{item.label}
</Link>
</li>
);
})}
</ul>
)}
</div>
);
})}
</nav>
</aside>

View File

@ -0,0 +1,451 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, ChevronDown } from 'lucide-react';
import {
useOpportunities,
useDeleteOpportunity,
useCreateOpportunity,
useUpdateOpportunity,
useUpdateOpportunityStatus,
} from '../../../hooks/useBidding';
import {
Opportunity,
OpportunityStatus,
CreateOpportunityDto,
} from '../../../services/bidding';
import clsx from 'clsx';
const statusColors: Record<OpportunityStatus, string> = {
lead: 'bg-gray-100 text-gray-800',
qualified: 'bg-blue-100 text-blue-800',
proposal: 'bg-purple-100 text-purple-800',
negotiation: 'bg-yellow-100 text-yellow-800',
won: 'bg-green-100 text-green-800',
lost: 'bg-red-100 text-red-800',
};
const statusLabels: Record<OpportunityStatus, string> = {
lead: 'Lead',
qualified: 'Calificado',
proposal: 'Propuesta',
negotiation: 'Negociacion',
won: 'Ganado',
lost: 'Perdido',
};
const allStatuses: OpportunityStatus[] = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'];
export function OpportunitiesPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<OpportunityStatus | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Opportunity | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [statusDropdown, setStatusDropdown] = useState<string | null>(null);
const { data, isLoading, error } = useOpportunities({
status: statusFilter || undefined,
clientName: search || undefined,
});
const deleteMutation = useDeleteOpportunity();
const createMutation = useCreateOpportunity();
const updateMutation = useUpdateOpportunity();
const updateStatusMutation = useUpdateOpportunityStatus();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const handleSubmit = async (formData: CreateOpportunityDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
const handleStatusChange = async (id: string, status: OpportunityStatus) => {
await updateStatusMutation.mutateAsync({ id, status });
setStatusDropdown(null);
};
const opportunities = data?.items || [];
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">Oportunidades</h1>
<p className="text-gray-600">Gestion del pipeline de ventas</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 Oportunidad
</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 cliente..."
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 OpportunityStatus | '')}
>
<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>
) : opportunities.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay oportunidades</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">
Titulo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Cliente
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Valor Estimado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Probabilidad
</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">
Cierre Esperado
</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">
{opportunities.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.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.clientName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(item.estimatedValue)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.probability}%
</td>
<td className="px-6 py-4 whitespace-nowrap relative">
<div className="relative inline-block">
<button
onClick={() => setStatusDropdown(statusDropdown === item.id ? null : item.id)}
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full flex items-center gap-1',
statusColors[item.status]
)}
>
{statusLabels[item.status]}
<ChevronDown className="w-3 h-3" />
</button>
{statusDropdown === item.id && (
<div className="absolute z-10 mt-1 bg-white border rounded-lg shadow-lg py-1 min-w-[140px]">
{allStatuses.map((status) => (
<button
key={status}
className={clsx(
'w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100',
status === item.status && 'bg-gray-50 font-medium'
)}
onClick={() => handleStatusChange(item.id, status)}
>
<span
className={clsx(
'inline-block w-2 h-2 rounded-full mr-2',
statusColors[status].replace('bg-', 'bg-').split(' ')[0]
)}
/>
{statusLabels[status]}
</button>
))}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.expectedCloseDate
? new Date(item.expectedCloseDate).toLocaleDateString()
: '-'}
</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 && (
<OpportunityModal
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 esta oportunidad? 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 OpportunityModalProps {
item: Opportunity | null;
onClose: () => void;
onSubmit: (data: CreateOpportunityDto) => Promise<void>;
isLoading: boolean;
}
function OpportunityModal({ item, onClose, onSubmit, isLoading }: OpportunityModalProps) {
const [formData, setFormData] = useState<CreateOpportunityDto>({
title: item?.title || '',
clientName: item?.clientName || '',
estimatedValue: item?.estimatedValue || 0,
probability: item?.probability || 50,
description: item?.description || '',
status: item?.status || 'lead',
expectedCloseDate: item?.expectedCloseDate?.split('T')[0] || '',
source: item?.source || '',
});
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 Oportunidad' : 'Nueva Oportunidad'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titulo *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cliente *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.clientName}
onChange={(e) => setFormData({ ...formData, clientName: 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">
Valor Estimado *
</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.estimatedValue}
onChange={(e) => setFormData({ ...formData, estimatedValue: parseFloat(e.target.value) || 0 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Probabilidad (%) *
</label>
<input
type="number"
required
min="0"
max="100"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.probability}
onChange={(e) => setFormData({ ...formData, probability: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<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 OpportunityStatus })}
>
{allStatuses.map((status) => (
<option key={status} value={status}>
{statusLabels[status]}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Cierre Esperado
</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.expectedCloseDate}
onChange={(e) => setFormData({ ...formData, expectedCloseDate: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fuente
</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Referido, Web, Llamada, etc."
value={formData.source}
onChange={(e) => setFormData({ ...formData, source: 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.description}
onChange={(e) => setFormData({ ...formData, description: 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>
);
}

View File

@ -0,0 +1,451 @@
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>
);
}

View File

@ -0,0 +1,451 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, ChevronDown } from 'lucide-react';
import {
useTenders,
useDeleteTender,
useCreateTender,
useUpdateTender,
useUpdateTenderStatus,
} from '../../../hooks/useBidding';
import {
Tender,
TenderStatus,
CreateTenderDto,
} from '../../../services/bidding';
import clsx from 'clsx';
const statusColors: Record<TenderStatus, string> = {
draft: 'bg-gray-100 text-gray-800',
published: 'bg-blue-100 text-blue-800',
evaluation: 'bg-yellow-100 text-yellow-800',
awarded: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
const statusLabels: Record<TenderStatus, string> = {
draft: 'Borrador',
published: 'Publicada',
evaluation: 'En Evaluacion',
awarded: 'Adjudicada',
cancelled: 'Cancelada',
};
const allStatuses: TenderStatus[] = ['draft', 'published', 'evaluation', 'awarded', 'cancelled'];
export function TendersPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<TenderStatus | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Tender | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [statusDropdown, setStatusDropdown] = useState<string | null>(null);
const { data, isLoading, error } = useTenders({
status: statusFilter || undefined,
issuingEntity: search || undefined,
});
const deleteMutation = useDeleteTender();
const createMutation = useCreateTender();
const updateMutation = useUpdateTender();
const updateStatusMutation = useUpdateTenderStatus();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const handleSubmit = async (formData: CreateTenderDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
const handleStatusChange = async (id: string, status: TenderStatus) => {
await updateStatusMutation.mutateAsync({ id, status });
setStatusDropdown(null);
};
const tenders = data?.items || [];
const formatCurrency = (value: number | undefined) => {
if (value === undefined || value === null) return '-';
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">Licitaciones</h1>
<p className="text-gray-600">Gestion de licitaciones y concursos</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 Licitacion
</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 entidad emisora..."
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 TenderStatus | '')}
>
<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>
) : tenders.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay licitaciones</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. Referencia
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Titulo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Entidad Emisora
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Presupuesto Est.
</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 Limite
</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">
{tenders.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.referenceNumber}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 max-w-xs truncate">
{item.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.issuingEntity}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(item.estimatedBudget)}
</td>
<td className="px-6 py-4 whitespace-nowrap relative">
<div className="relative inline-block">
<button
onClick={() => setStatusDropdown(statusDropdown === item.id ? null : item.id)}
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full flex items-center gap-1',
statusColors[item.status]
)}
>
{statusLabels[item.status]}
<ChevronDown className="w-3 h-3" />
</button>
{statusDropdown === item.id && (
<div className="absolute z-10 mt-1 bg-white border rounded-lg shadow-lg py-1 min-w-[140px]">
{allStatuses.map((status) => (
<button
key={status}
className={clsx(
'w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100',
status === item.status && 'bg-gray-50 font-medium'
)}
onClick={() => handleStatusChange(item.id, status)}
>
{statusLabels[status]}
</button>
))}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(item.submissionDeadline).toLocaleDateString()}
</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 && (
<TenderModal
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 esta licitacion? 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 TenderModalProps {
item: Tender | null;
onClose: () => void;
onSubmit: (data: CreateTenderDto) => Promise<void>;
isLoading: boolean;
}
function TenderModal({ item, onClose, onSubmit, isLoading }: TenderModalProps) {
const [formData, setFormData] = useState<CreateTenderDto>({
referenceNumber: item?.referenceNumber || '',
title: item?.title || '',
issuingEntity: item?.issuingEntity || '',
submissionDeadline: item?.submissionDeadline?.split('T')[0] || '',
description: item?.description || '',
estimatedBudget: item?.estimatedBudget || undefined,
status: item?.status || 'draft',
publicationDate: item?.publicationDate?.split('T')[0] || '',
openingDate: item?.openingDate?.split('T')[0] || '',
});
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 Licitacion' : 'Nueva Licitacion'}
</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">
No. Referencia *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.referenceNumber}
onChange={(e) => setFormData({ ...formData, referenceNumber: 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 TenderStatus })}
>
{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">
Titulo *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Entidad Emisora *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.issuingEntity}
onChange={(e) => setFormData({ ...formData, issuingEntity: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Presupuesto Estimado
</label>
<input
type="number"
min="0"
step="0.01"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.estimatedBudget || ''}
onChange={(e) => setFormData({ ...formData, estimatedBudget: e.target.value ? parseFloat(e.target.value) : undefined })}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Publicacion
</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.publicationDate}
onChange={(e) => setFormData({ ...formData, publicationDate: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Limite *
</label>
<input
type="date"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.submissionDeadline}
onChange={(e) => setFormData({ ...formData, submissionDeadline: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Apertura
</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.openingDate}
onChange={(e) => setFormData({ ...formData, openingDate: e.target.value })}
/>
</div>
</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.description}
onChange={(e) => setFormData({ ...formData, description: 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>
);
}

View File

@ -0,0 +1,370 @@
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>
);
}

View File

@ -0,0 +1,4 @@
export { OpportunitiesPage } from './OpportunitiesPage';
export { TendersPage } from './TendersPage';
export { ProposalsPage } from './ProposalsPage';
export { VendorsPage } from './VendorsPage';

View File

@ -0,0 +1,709 @@
import { useState, useMemo } from 'react';
import {
Building2,
Activity,
DollarSign,
TrendingUp,
AlertTriangle,
Search,
CheckCircle,
Clock,
XCircle,
AlertCircle,
Info,
Gauge,
} from 'lucide-react';
import clsx from 'clsx';
import {
useDashboardStats,
useProjectsSummary,
useProjectKPIs,
useAlerts,
useAcknowledgeAlert,
} from '../../../hooks/useReports';
import {
EarnedValueStatus,
AlertSeverity,
} from '../../../services/reports';
// =============================================================================
// Utility Functions
// =============================================================================
function formatCurrency(value: number): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatNumber(value: number, decimals: number = 2): string {
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
// =============================================================================
// Types
// =============================================================================
const statusColors: Record<EarnedValueStatus, { bg: string; text: string; dot: string }> = {
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' },
};
const statusLabels: Record<EarnedValueStatus, string> = {
green: 'En Tiempo',
yellow: 'En Riesgo',
red: 'Atrasado',
};
const severityConfig: Record<
AlertSeverity,
{ bg: string; text: string; border: string; icon: typeof AlertTriangle }
> = {
critical: {
bg: 'bg-red-50',
text: 'text-red-700',
border: 'border-red-200',
icon: XCircle,
},
warning: {
bg: 'bg-yellow-50',
text: 'text-yellow-700',
border: 'border-yellow-200',
icon: AlertCircle,
},
info: {
bg: 'bg-blue-50',
text: 'text-blue-700',
border: 'border-blue-200',
icon: Info,
},
};
// =============================================================================
// Sub-Components
// =============================================================================
interface StatCardProps {
title: string;
value: string | number;
icon: typeof Building2;
color: 'blue' | 'green' | 'purple' | 'orange' | 'red';
subtitle?: string;
isWarning?: boolean;
}
function StatCard({ title, value, icon: Icon, color, subtitle, isWarning }: StatCardProps) {
const colorClasses = {
blue: 'bg-blue-500',
green: 'bg-green-500',
purple: 'bg-purple-500',
orange: 'bg-orange-500',
red: 'bg-red-500',
};
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">{title}</p>
<p
className={clsx(
'text-2xl font-bold mt-1',
isWarning ? 'text-red-600' : 'text-gray-900'
)}
>
{value}
</p>
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
</div>
<div className={clsx('p-3 rounded-lg', colorClasses[color])}>
<Icon className="w-6 h-6 text-white" />
</div>
</div>
</div>
);
}
interface KPIGaugeProps {
label: string;
value: number;
threshold?: { warning: number; danger: number };
}
function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }: KPIGaugeProps) {
const getColor = (val: number) => {
if (val >= threshold.warning) return 'text-green-600';
if (val >= threshold.danger) return 'text-yellow-600';
return 'text-red-600';
};
const getBgColor = (val: number) => {
if (val >= threshold.warning) return 'bg-green-100';
if (val >= threshold.danger) return 'bg-yellow-100';
return 'bg-red-100';
};
const getProgressColor = (val: number) => {
if (val >= threshold.warning) return 'bg-green-500';
if (val >= threshold.danger) return 'bg-yellow-500';
return 'bg-red-500';
};
const percentage = Math.min(Math.max(value * 100, 0), 150);
const displayPercentage = Math.min(percentage, 100);
return (
<div className={clsx('rounded-lg p-4', getBgColor(value))}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<Gauge className={clsx('w-5 h-5', getColor(value))} />
</div>
<div className={clsx('text-3xl font-bold', getColor(value))}>
{formatNumber(value, 2)}
</div>
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', getProgressColor(value))}
style={{ width: `${displayPercentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>1.0</span>
</div>
</div>
);
}
interface EVMValueCardProps {
label: string;
value: number;
description?: string;
}
function EVMValueCard({ label, value, description }: EVMValueCardProps) {
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-xl font-semibold text-gray-900 mt-1">{formatCurrency(value)}</p>
{description && <p className="text-xs text-gray-400 mt-1">{description}</p>}
</div>
);
}
// =============================================================================
// Main Component
// =============================================================================
export function DashboardPage() {
const [search, setSearch] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<EarnedValueStatus | ''>('');
// Data hooks
const { data: stats, isLoading: statsLoading } = useDashboardStats();
const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({
status: statusFilter || undefined,
});
const { data: kpis, isLoading: kpisLoading } = useProjectKPIs(selectedProjectId || '');
const { data: alertsData, isLoading: alertsLoading } = useAlerts({ acknowledged: false });
const acknowledgeMutation = useAcknowledgeAlert();
// Filter projects by search
const filteredProjects = useMemo(() => {
if (!projectsData?.items) return [];
if (!search) return projectsData.items;
const searchLower = search.toLowerCase();
return projectsData.items.filter((p) =>
p.nombre.toLowerCase().includes(searchLower)
);
}, [projectsData?.items, search]);
// Get selected project name
const selectedProject = useMemo(() => {
if (!selectedProjectId || !projectsData?.items) return null;
return projectsData.items.find((p) => p.id === selectedProjectId);
}, [selectedProjectId, projectsData?.items]);
const handleAcknowledge = async (alertId: string) => {
await acknowledgeMutation.mutateAsync(alertId);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Vision general del portafolio de proyectos</p>
</div>
{/* Stats Cards Row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{statsLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="bg-white rounded-lg shadow-sm p-6 animate-pulse"
>
<div className="h-4 bg-gray-200 rounded w-24 mb-2" />
<div className="h-8 bg-gray-200 rounded w-16" />
</div>
))
) : stats ? (
<>
<StatCard
title="Total Proyectos"
value={stats.totalProyectos}
icon={Building2}
color="blue"
/>
<StatCard
title="Proyectos Activos"
value={stats.proyectosActivos}
icon={Activity}
color="green"
/>
<StatCard
title="Presupuesto Total"
value={formatCurrency(stats.presupuestoTotal)}
icon={DollarSign}
color="purple"
/>
<StatCard
title="Avance Promedio"
value={formatPercent(stats.avancePromedio / 100)}
icon={TrendingUp}
color="orange"
/>
<StatCard
title="Alertas Activas"
value={stats.alertasActivas}
icon={AlertTriangle}
color={stats.alertasActivas > 0 ? 'red' : 'blue'}
isWarning={stats.alertasActivas > 0}
/>
</>
) : null}
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Projects Summary Table */}
<div className="lg:col-span-2 bg-white rounded-lg shadow-sm">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">Resumen de Proyectos</h2>
<div className="mt-3 flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar proyecto..."
className="w-full pl-9 pr-4 py-2 text-sm 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-3 py-2 text-sm border rounded-lg focus:ring-2 focus:ring-blue-500"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as EarnedValueStatus | '')}
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
<div className="overflow-x-auto">
{projectsLoading ? (
<div className="p-8 text-center text-gray-500">Cargando proyectos...</div>
) : filteredProjects.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No hay proyectos que coincidan con la busqueda
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Nombre
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Presupuesto
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Real
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Programado
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
SPI
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
CPI
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredProjects.map((project) => (
<tr
key={project.id}
className={clsx(
'cursor-pointer transition-colors',
selectedProjectId === project.id
? 'bg-blue-50'
: 'hover:bg-gray-50'
)}
onClick={() => setSelectedProjectId(project.id)}
>
<td className="px-4 py-3 text-sm font-medium text-gray-900">
{project.nombre}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">
{formatCurrency(project.presupuesto)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">
{formatPercent(project.avanceReal / 100)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">
{formatPercent(project.avanceProgramado / 100)}
</td>
<td className="px-4 py-3 text-center">
<span
className={clsx(
'text-sm font-medium',
project.spi >= 0.95
? 'text-green-600'
: project.spi >= 0.85
? 'text-yellow-600'
: 'text-red-600'
)}
>
{formatNumber(project.spi)}
</span>
</td>
<td className="px-4 py-3 text-center">
<span
className={clsx(
'text-sm font-medium',
project.cpi >= 0.95
? 'text-green-600'
: project.cpi >= 0.85
? 'text-yellow-600'
: 'text-red-600'
)}
>
{formatNumber(project.cpi)}
</span>
</td>
<td className="px-4 py-3 text-center">
<span
className={clsx(
'inline-flex items-center px-2 py-1 text-xs font-medium rounded-full',
statusColors[project.status].bg,
statusColors[project.status].text
)}
>
<span
className={clsx(
'w-1.5 h-1.5 rounded-full mr-1.5',
statusColors[project.status].dot
)}
/>
{statusLabels[project.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Alerts Section */}
<div className="bg-white rounded-lg shadow-sm">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Alertas Recientes</h2>
{alertsData?.items && alertsData.items.length > 0 && (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
{alertsData.items.length}
</span>
)}
</div>
<div className="p-4 space-y-3 max-h-96 overflow-y-auto">
{alertsLoading ? (
<div className="text-center text-gray-500 py-4">Cargando alertas...</div>
) : !alertsData?.items || alertsData.items.length === 0 ? (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-2" />
<p className="text-gray-500">No hay alertas pendientes</p>
</div>
) : (
alertsData.items.slice(0, 10).map((alert) => {
const config = severityConfig[alert.severity];
const IconComponent = config.icon;
return (
<div
key={alert.id}
className={clsx(
'p-3 rounded-lg border',
config.bg,
config.border
)}
>
<div className="flex items-start gap-3">
<IconComponent className={clsx('w-5 h-5 mt-0.5', config.text)} />
<div className="flex-1 min-w-0">
<p className={clsx('text-sm font-medium', config.text)}>
{alert.title}
</p>
<p className="text-xs text-gray-600 mt-1">{alert.projectName}</p>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{alert.message}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-400 flex items-center">
<Clock className="w-3 h-3 mr-1" />
{new Date(alert.createdAt).toLocaleDateString()}
</span>
<button
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
onClick={(e) => {
e.stopPropagation();
handleAcknowledge(alert.id);
}}
disabled={acknowledgeMutation.isPending}
>
{acknowledgeMutation.isPending ? 'Procesando...' : 'Reconocer'}
</button>
</div>
</div>
</div>
</div>
);
})
)}
</div>
</div>
</div>
{/* KPI Indicators Section - Shows when a project is selected */}
{selectedProjectId && (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Indicadores KPI - {selectedProject?.nombre || 'Proyecto'}
</h2>
<p className="text-sm text-gray-500">
Earned Value Management (EVM)
</p>
</div>
<button
className="text-sm text-gray-500 hover:text-gray-700"
onClick={() => setSelectedProjectId(null)}
>
Cerrar
</button>
</div>
{kpisLoading ? (
<div className="text-center text-gray-500 py-8">Cargando KPIs...</div>
) : !kpis ? (
<div className="text-center text-gray-500 py-8">
No hay datos de KPIs disponibles
</div>
) : (
<div className="space-y-6">
{/* Performance Indicators */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">
Indicadores de Desempeno
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<KPIGauge label="SPI (Schedule)" value={kpis.spi} />
<KPIGauge label="CPI (Cost)" value={kpis.cpi} />
<KPIGauge
label="TCPI (To Complete)"
value={kpis.tcpi}
threshold={{ warning: 1.0, danger: 1.1 }}
/>
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">% Completado</span>
<TrendingUp className="w-5 h-5 text-blue-600" />
</div>
<div className="text-3xl font-bold text-blue-600">
{formatPercent(kpis.percentComplete / 100)}
</div>
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(kpis.percentComplete, 100)}%` }}
/>
</div>
</div>
</div>
</div>
{/* EV Values */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">
Valores de Earned Value
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EVMValueCard
label="PV (Planned Value)"
value={kpis.pv}
description="Valor planeado a la fecha"
/>
<EVMValueCard
label="EV (Earned Value)"
value={kpis.ev}
description="Valor ganado (trabajo realizado)"
/>
<EVMValueCard
label="AC (Actual Cost)"
value={kpis.ac}
description="Costo real incurrido"
/>
</div>
</div>
{/* Variances */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Varianzas</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
className={clsx(
'rounded-lg p-4',
kpis.sv >= 0 ? 'bg-green-50' : 'bg-red-50'
)}
>
<p className="text-sm text-gray-600">SV (Schedule Variance)</p>
<p
className={clsx(
'text-xl font-semibold mt-1',
kpis.sv >= 0 ? 'text-green-700' : 'text-red-700'
)}
>
{formatCurrency(kpis.sv)}
</p>
<p className="text-xs text-gray-500 mt-1">
{kpis.sv >= 0 ? 'Adelantado' : 'Atrasado'}
</p>
</div>
<div
className={clsx(
'rounded-lg p-4',
kpis.cv >= 0 ? 'bg-green-50' : 'bg-red-50'
)}
>
<p className="text-sm text-gray-600">CV (Cost Variance)</p>
<p
className={clsx(
'text-xl font-semibold mt-1',
kpis.cv >= 0 ? 'text-green-700' : 'text-red-700'
)}
>
{formatCurrency(kpis.cv)}
</p>
<p className="text-xs text-gray-500 mt-1">
{kpis.cv >= 0 ? 'Bajo presupuesto' : 'Sobre presupuesto'}
</p>
</div>
</div>
</div>
{/* Projections */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Proyecciones</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EVMValueCard
label="BAC (Budget at Completion)"
value={kpis.bac}
description="Presupuesto total del proyecto"
/>
<EVMValueCard
label="EAC (Estimate at Completion)"
value={kpis.eac}
description="Costo estimado final"
/>
<EVMValueCard
label="ETC (Estimate to Complete)"
value={kpis.etc}
description="Costo estimado para terminar"
/>
</div>
</div>
{/* VAC */}
<div>
<div
className={clsx(
'rounded-lg p-4',
kpis.vac >= 0 ? 'bg-green-50' : 'bg-red-50'
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">VAC (Variance at Completion)</p>
<p
className={clsx(
'text-2xl font-bold mt-1',
kpis.vac >= 0 ? 'text-green-700' : 'text-red-700'
)}
>
{formatCurrency(kpis.vac)}
</p>
</div>
<div
className={clsx(
'p-3 rounded-full',
kpis.vac >= 0 ? 'bg-green-100' : 'bg-red-100'
)}
>
{kpis.vac >= 0 ? (
<TrendingUp className="w-8 h-8 text-green-600" />
) : (
<AlertTriangle className="w-8 h-8 text-red-600" />
)}
</div>
</div>
<p className="text-sm text-gray-500 mt-2">
{kpis.vac >= 0
? `Se proyecta un ahorro de ${formatCurrency(kpis.vac)} al finalizar el proyecto`
: `Se proyecta un sobrecosto de ${formatCurrency(Math.abs(kpis.vac))} al finalizar el proyecto`}
</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { DashboardPage } from './DashboardPage';

View File

@ -0,0 +1,547 @@
import { useState } from 'react';
import {
Plus,
Pencil,
Trash2,
Search,
ChevronRight,
ChevronDown,
FolderTree,
FileText,
} from 'lucide-react';
import {
useConceptosTree,
useCreateConcepto,
useUpdateConcepto,
useDeleteConcepto,
} from '../../../hooks/usePresupuestos';
import {
Concepto,
ConceptoTipo,
CreateConceptoDto,
} from '../../../services/presupuestos';
import clsx from 'clsx';
const tipoLabels: Record<ConceptoTipo, string> = {
capitulo: 'Capitulo',
partida: 'Partida',
subpartida: 'Subpartida',
concepto: 'Concepto',
};
const tipoIcons: Record<ConceptoTipo, React.ReactNode> = {
capitulo: <FolderTree className="w-4 h-4 text-blue-600" />,
partida: <FolderTree className="w-4 h-4 text-green-600" />,
subpartida: <FolderTree className="w-4 h-4 text-yellow-600" />,
concepto: <FileText className="w-4 h-4 text-gray-600" />,
};
interface TreeNodeProps {
concepto: Concepto;
level: number;
expandedNodes: Set<string>;
onToggle: (id: string) => void;
onEdit: (concepto: Concepto) => void;
onDelete: (id: string) => void;
onAddChild: (parentId: string, parentTipo: ConceptoTipo) => void;
searchTerm: string;
}
function TreeNode({
concepto,
level,
expandedNodes,
onToggle,
onEdit,
onDelete,
onAddChild,
searchTerm,
}: TreeNodeProps) {
const isExpanded = expandedNodes.has(concepto.id);
const hasChildren = concepto.children && concepto.children.length > 0;
const matchesSearch =
!searchTerm ||
concepto.codigo.toLowerCase().includes(searchTerm.toLowerCase()) ||
concepto.descripcion.toLowerCase().includes(searchTerm.toLowerCase());
const childrenMatch =
concepto.children?.some(
(child) =>
child.codigo.toLowerCase().includes(searchTerm.toLowerCase()) ||
child.descripcion.toLowerCase().includes(searchTerm.toLowerCase()) ||
hasMatchingDescendant(child, searchTerm)
) ?? false;
if (searchTerm && !matchesSearch && !childrenMatch) {
return null;
}
const canAddChild = concepto.tipo !== 'concepto';
return (
<div>
<div
className={clsx(
'flex items-center py-2 px-3 hover:bg-gray-50 border-b border-gray-100',
matchesSearch && searchTerm && 'bg-yellow-50'
)}
style={{ paddingLeft: `${level * 24 + 12}px` }}
>
<button
className="mr-2 p-1 hover:bg-gray-200 rounded"
onClick={() => hasChildren && onToggle(concepto.id)}
disabled={!hasChildren}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)
) : (
<span className="w-4 h-4 inline-block" />
)}
</button>
<span className="mr-2">{tipoIcons[concepto.tipo]}</span>
<span className="font-mono text-sm text-gray-600 mr-3 min-w-[100px]">
{concepto.codigo}
</span>
<span className="flex-1 text-sm text-gray-900 truncate">
{concepto.descripcion}
</span>
{concepto.unidad && (
<span className="text-xs text-gray-500 mr-4 min-w-[60px] text-center">
{concepto.unidad}
</span>
)}
{concepto.precioUnitario !== undefined && concepto.precioUnitario !== null && (
<span className="text-sm text-gray-700 mr-4 min-w-[100px] text-right font-medium">
${concepto.precioUnitario.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</span>
)}
<div className="flex items-center gap-1">
{canAddChild && (
<button
className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded"
title="Agregar hijo"
onClick={() => onAddChild(concepto.id, concepto.tipo)}
>
<Plus className="w-4 h-4" />
</button>
)}
<button
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
title="Editar"
onClick={() => onEdit(concepto)}
>
<Pencil className="w-4 h-4" />
</button>
<button
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
title="Eliminar"
onClick={() => onDelete(concepto.id)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{isExpanded && hasChildren && (
<div>
{concepto.children!.map((child) => (
<TreeNode
key={child.id}
concepto={child}
level={level + 1}
expandedNodes={expandedNodes}
onToggle={onToggle}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
searchTerm={searchTerm}
/>
))}
</div>
)}
</div>
);
}
function hasMatchingDescendant(concepto: Concepto, searchTerm: string): boolean {
if (!concepto.children) return false;
return concepto.children.some(
(child) =>
child.codigo.toLowerCase().includes(searchTerm.toLowerCase()) ||
child.descripcion.toLowerCase().includes(searchTerm.toLowerCase()) ||
hasMatchingDescendant(child, searchTerm)
);
}
export function ConceptosPage() {
const [search, setSearch] = useState('');
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Concepto | null>(null);
const [parentIdForNew, setParentIdForNew] = useState<string | undefined>(undefined);
const [parentTipoForNew, setParentTipoForNew] = useState<ConceptoTipo | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const { data: conceptosTree, isLoading, error } = useConceptosTree();
const deleteMutation = useDeleteConcepto();
const createMutation = useCreateConcepto();
const updateMutation = useUpdateConcepto();
const handleToggle = (id: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleExpandAll = () => {
if (!conceptosTree) return;
const allIds = new Set<string>();
const collectIds = (conceptos: Concepto[]) => {
conceptos.forEach((c) => {
allIds.add(c.id);
if (c.children) collectIds(c.children);
});
};
collectIds(conceptosTree);
setExpandedNodes(allIds);
};
const handleCollapseAll = () => {
setExpandedNodes(new Set());
};
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const handleSubmit = async (formData: CreateConceptoDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
setParentIdForNew(undefined);
setParentTipoForNew(undefined);
};
const handleAddChild = (parentId: string, parentTipo: ConceptoTipo) => {
setParentIdForNew(parentId);
setParentTipoForNew(parentTipo);
setEditingItem(null);
setShowModal(true);
setExpandedNodes((prev) => new Set([...prev, parentId]));
};
const handleOpenNewRoot = () => {
setParentIdForNew(undefined);
setParentTipoForNew(undefined);
setEditingItem(null);
setShowModal(true);
};
const conceptos = conceptosTree || [];
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Catalogo de Conceptos</h1>
<p className="text-gray-600">Gestion jerarquica de capitulos, partidas y conceptos</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={handleOpenNewRoot}
>
<Plus className="w-5 h-5 mr-2" />
Nuevo Capitulo
</button>
</div>
<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 descripcion..."
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>
<div className="flex gap-2">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={handleExpandAll}
>
Expandir Todo
</button>
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={handleCollapseAll}
>
Colapsar Todo
</button>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b flex items-center text-xs font-medium text-gray-500 uppercase">
<span className="w-8" />
<span className="w-8" />
<span className="min-w-[100px] mr-3">Codigo</span>
<span className="flex-1">Descripcion</span>
<span className="min-w-[60px] text-center mr-4">Unidad</span>
<span className="min-w-[100px] text-right mr-4">Precio Unitario</span>
<span className="w-28 text-right">Acciones</span>
</div>
{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>
) : conceptos.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No hay conceptos. Crea el primer capitulo para comenzar.
</div>
) : (
<div>
{conceptos.map((concepto) => (
<TreeNode
key={concepto.id}
concepto={concepto}
level={0}
expandedNodes={expandedNodes}
onToggle={handleToggle}
onEdit={(c) => {
setEditingItem(c);
setParentIdForNew(c.parentId);
setShowModal(true);
}}
onDelete={setDeleteConfirm}
onAddChild={handleAddChild}
searchTerm={search}
/>
))}
</div>
)}
</div>
{showModal && (
<ConceptoModal
item={editingItem}
parentId={parentIdForNew}
parentTipo={parentTipoForNew}
onClose={() => {
setShowModal(false);
setEditingItem(null);
setParentIdForNew(undefined);
setParentTipoForNew(undefined);
}}
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 concepto? Si tiene elementos hijos, tambien seran eliminados.
</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>
);
}
interface ConceptoModalProps {
item: Concepto | null;
parentId?: string;
parentTipo?: ConceptoTipo;
onClose: () => void;
onSubmit: (data: CreateConceptoDto) => Promise<void>;
isLoading: boolean;
}
function getChildTipo(parentTipo?: ConceptoTipo): ConceptoTipo {
if (!parentTipo) return 'capitulo';
switch (parentTipo) {
case 'capitulo':
return 'partida';
case 'partida':
return 'subpartida';
case 'subpartida':
return 'concepto';
default:
return 'concepto';
}
}
function ConceptoModal({
item,
parentId,
parentTipo,
onClose,
onSubmit,
isLoading,
}: ConceptoModalProps) {
const defaultTipo = item?.tipo || getChildTipo(parentTipo);
const [formData, setFormData] = useState<CreateConceptoDto>({
codigo: item?.codigo || '',
descripcion: item?.descripcion || '',
tipo: defaultTipo,
parentId: item?.parentId || parentId,
unidad: item?.unidad || '',
precioUnitario: item?.precioUnitario,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit({
...formData,
precioUnitario: formData.precioUnitario || undefined,
unidad: formData.unidad || undefined,
});
};
const showPriceFields = formData.tipo === 'concepto' || formData.tipo === 'subpartida';
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 ${tipoLabels[item.tipo]}` : `Nuevo ${tipoLabels[defaultTipo]}`}
</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">Tipo</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 bg-gray-50"
value={formData.tipo}
disabled
>
{Object.entries(tipoLabels).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">Descripcion *</label>
<textarea
required
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>
{showPriceFields && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unidad</label>
<input
type="text"
placeholder="m2, m3, pza, kg..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.unidad || ''}
onChange={(e) => setFormData({ ...formData, unidad: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Precio Unitario
</label>
<input
type="number"
step="0.01"
min="0"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.precioUnitario || ''}
onChange={(e) =>
setFormData({
...formData,
precioUnitario: 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>
);
}

View File

@ -0,0 +1,837 @@
import { useState } from 'react';
import {
Plus,
Pencil,
Trash2,
Search,
Send,
CheckCircle,
XCircle,
Eye,
ChevronDown,
ChevronRight,
FileText,
} from 'lucide-react';
import {
useEstimaciones,
useEstimacion,
useCreateEstimacion,
useUpdateEstimacion,
useDeleteEstimacion,
useSubmitEstimacion,
useApproveEstimacion,
useRejectEstimacion,
} from '../../../hooks/usePresupuestos';
import {
Estimacion,
EstimacionEstado,
EstimacionPartida,
CreateEstimacionDto,
} from '../../../services/presupuestos';
import clsx from 'clsx';
const estadoColors: Record<EstimacionEstado, string> = {
borrador: 'bg-gray-100 text-gray-800',
revision: 'bg-yellow-100 text-yellow-800',
aprobado: 'bg-green-100 text-green-800',
facturado: 'bg-blue-100 text-blue-800',
cobrado: 'bg-purple-100 text-purple-800',
rechazado: 'bg-red-100 text-red-800',
};
const estadoLabels: Record<EstimacionEstado, string> = {
borrador: 'Borrador',
revision: 'En Revision',
aprobado: 'Aprobado',
facturado: 'Facturado',
cobrado: 'Cobrado',
rechazado: 'Rechazado',
};
export function EstimacionesPage() {
const [search, setSearch] = useState('');
const [estadoFilter, setEstadoFilter] = useState<EstimacionEstado | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Estimacion | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [detailId, setDetailId] = useState<string | null>(null);
const [rejectModal, setRejectModal] = useState<string | null>(null);
const [approveModal, setApproveModal] = useState<string | null>(null);
const { data, isLoading, error } = useEstimaciones({
estado: estadoFilter || undefined,
});
const deleteMutation = useDeleteEstimacion();
const createMutation = useCreateEstimacion();
const updateMutation = useUpdateEstimacion();
const submitMutation = useSubmitEstimacion();
const approveMutation = useApproveEstimacion();
const rejectMutation = useRejectEstimacion();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const handleSubmitEstimacion = async (id: string) => {
await submitMutation.mutateAsync(id);
};
const handleApprove = async (id: string, montoAprobado?: number) => {
await approveMutation.mutateAsync({ id, montoAprobado });
setApproveModal(null);
};
const handleReject = async (id: string, motivo: string) => {
await rejectMutation.mutateAsync({ id, motivo });
setRejectModal(null);
};
const handleSubmit = async (formData: CreateEstimacionDto) => {
if (editingItem) {
await updateMutation.mutateAsync({
id: editingItem.id,
data: {
periodo: formData.periodo,
fechaInicio: formData.fechaInicio,
fechaFin: formData.fechaFin,
anticipoPorcentaje: formData.anticipoPorcentaje,
retencionPorcentaje: formData.retencionPorcentaje,
observaciones: formData.observaciones,
},
});
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
const estimaciones = (data?.items || []).filter(
(e) =>
!search ||
e.numero.toString().includes(search) ||
e.periodo.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Estimaciones</h1>
<p className="text-gray-600">Gestion de estimaciones 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" />
Nueva Estimacion
</button>
</div>
<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 periodo..."
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={estadoFilter}
onChange={(e) => setEstadoFilter(e.target.value as EstimacionEstado | '')}
>
<option value="">Todos los estados</option>
{Object.entries(estadoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</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>
) : estimaciones.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay estimaciones</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">
Numero
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Periodo
</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 Estimado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Monto Aprobado
</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">
{estimaciones.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">
Est-{item.numero.toString().padStart(3, '0')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.periodo}
</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">
${item.montoEstimado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
{item.montoAprobado !== undefined && item.montoAprobado !== null
? `$${item.montoAprobado.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">
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
onClick={() => setDetailId(item.id)}
>
<Eye className="w-4 h-4" />
</button>
{item.estado === 'borrador' && (
<button
className="p-2 text-gray-500 hover:text-yellow-600 hover:bg-yellow-50 rounded-lg"
title="Enviar a revision"
onClick={() => handleSubmitEstimacion(item.id)}
disabled={submitMutation.isPending}
>
<Send className="w-4 h-4" />
</button>
)}
{item.estado === 'revision' && (
<>
<button
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Aprobar"
onClick={() => setApproveModal(item.id)}
>
<CheckCircle className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Rechazar"
onClick={() => setRejectModal(item.id)}
>
<XCircle className="w-4 h-4" />
</button>
</>
)}
{item.estado === 'borrador' && (
<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>
{showModal && (
<EstimacionModal
item={editingItem}
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 esta estimacion? 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>
)}
{approveModal && (
<ApproveModal
estimacionId={approveModal}
onClose={() => setApproveModal(null)}
onApprove={handleApprove}
isLoading={approveMutation.isPending}
/>
)}
{rejectModal && (
<RejectModal
estimacionId={rejectModal}
onClose={() => setRejectModal(null)}
onReject={handleReject}
isLoading={rejectMutation.isPending}
/>
)}
{detailId && (
<EstimacionDetailModal
estimacionId={detailId}
onClose={() => setDetailId(null)}
/>
)}
</div>
);
}
interface EstimacionModalProps {
item: Estimacion | null;
onClose: () => void;
onSubmit: (data: CreateEstimacionDto) => Promise<void>;
isLoading: boolean;
}
function EstimacionModal({ item, onClose, onSubmit, isLoading }: EstimacionModalProps) {
const [formData, setFormData] = useState<CreateEstimacionDto>({
presupuestoId: item?.presupuestoId || 'default-presupuesto-id',
proyectoId: item?.proyectoId || 'default-project-id',
periodo: item?.periodo || '',
fechaInicio: item?.fechaInicio?.split('T')[0] || '',
fechaFin: item?.fechaFin?.split('T')[0] || '',
anticipoPorcentaje: item?.anticipoPorcentaje,
retencionPorcentaje: item?.retencionPorcentaje,
observaciones: item?.observaciones || '',
});
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 Estimacion' : 'Nueva Estimacion'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Periodo *</label>
<input
type="text"
required
placeholder="Ej: Enero 2026, Semana 1-4"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.periodo}
onChange={(e) => setFormData({ ...formData, periodo: 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"
required
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 *</label>
<input
type="date"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.fechaFin}
onChange={(e) => setFormData({ ...formData, fechaFin: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Anticipo (%)
</label>
<input
type="number"
step="0.01"
min="0"
max="100"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.anticipoPorcentaje || ''}
onChange={(e) =>
setFormData({
...formData,
anticipoPorcentaje: e.target.value ? parseFloat(e.target.value) : undefined,
})
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Retencion (%)
</label>
<input
type="number"
step="0.01"
min="0"
max="100"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.retencionPorcentaje || ''}
onChange={(e) =>
setFormData({
...formData,
retencionPorcentaje: e.target.value ? parseFloat(e.target.value) : undefined,
})
}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Observaciones</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
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' : 'Crear'}
</button>
</div>
</form>
</div>
</div>
);
}
interface ApproveModalProps {
estimacionId: string;
onClose: () => void;
onApprove: (id: string, montoAprobado?: number) => Promise<void>;
isLoading: boolean;
}
function ApproveModal({ estimacionId, onClose, onApprove, isLoading }: ApproveModalProps) {
const [montoAprobado, setMontoAprobado] = useState<string>('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onApprove(
estimacionId,
montoAprobado ? parseFloat(montoAprobado) : 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">
<h3 className="text-lg font-semibold mb-4">Aprobar Estimacion</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Monto Aprobado (opcional)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="Dejar vacio para usar monto estimado"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={montoAprobado}
onChange={(e) => setMontoAprobado(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-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Aprobando...' : 'Aprobar'}
</button>
</div>
</form>
</div>
</div>
);
}
interface RejectModalProps {
estimacionId: string;
onClose: () => void;
onReject: (id: string, motivo: string) => Promise<void>;
isLoading: boolean;
}
function RejectModal({ estimacionId, onClose, onReject, isLoading }: RejectModalProps) {
const [motivo, setMotivo] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onReject(estimacionId, motivo);
};
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">
<h3 className="text-lg font-semibold mb-4">Rechazar Estimacion</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Motivo *</label>
<textarea
required
rows={3}
placeholder="Ingrese el motivo del rechazo..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={motivo}
onChange={(e) => setMotivo(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-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Rechazando...' : 'Rechazar'}
</button>
</div>
</form>
</div>
</div>
);
}
interface EstimacionDetailModalProps {
estimacionId: string;
onClose: () => void;
}
function EstimacionDetailModal({ estimacionId, onClose }: EstimacionDetailModalProps) {
const { data: estimacion, isLoading } = useEstimacion(estimacionId);
const [expandedPartidas, setExpandedPartidas] = useState<Set<string>>(new Set());
const togglePartida = (id: string) => {
setExpandedPartidas((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
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-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Detalle de Estimacion</h3>
<button
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg"
onClick={onClose}
>
<XCircle className="w-5 h-5" />
</button>
</div>
{isLoading ? (
<div className="p-8 text-center text-gray-500">Cargando...</div>
) : !estimacion ? (
<div className="p-8 text-center text-red-500">Error al cargar la estimacion</div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">Numero</p>
<p className="text-lg font-semibold">
Est-{estimacion.numero.toString().padStart(3, '0')}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">Periodo</p>
<p className="text-lg font-semibold">{estimacion.periodo}</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">Estado</p>
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
estadoColors[estimacion.estado]
)}
>
{estadoLabels[estimacion.estado]}
</span>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">Monto Estimado</p>
<p className="text-lg font-semibold text-green-600">
${estimacion.montoEstimado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
</div>
</div>
{estimacion.montoAprobado !== undefined && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-green-50 p-4 rounded-lg">
<p className="text-sm text-green-700">Monto Aprobado</p>
<p className="text-lg font-semibold text-green-800">
${estimacion.montoAprobado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
</div>
{estimacion.montoFacturado !== undefined && (
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-blue-700">Monto Facturado</p>
<p className="text-lg font-semibold text-blue-800">
${estimacion.montoFacturado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
</div>
)}
{estimacion.montoCobrado !== undefined && (
<div className="bg-purple-50 p-4 rounded-lg">
<p className="text-sm text-purple-700">Monto Cobrado</p>
<p className="text-lg font-semibold text-purple-800">
${estimacion.montoCobrado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
</div>
)}
</div>
)}
<div>
<h4 className="font-semibold text-gray-900 mb-3 flex items-center">
<FileText className="w-5 h-5 mr-2" />
Partidas ({estimacion.partidas?.length || 0})
</h4>
{!estimacion.partidas || estimacion.partidas.length === 0 ? (
<p className="text-gray-500 text-center py-4">No hay partidas en esta estimacion</p>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase w-8"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Concepto
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
Cant. Est.
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
P.U.
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
Importe Est.
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{estimacion.partidas.map((partida) => (
<PartidaRow
key={partida.id}
partida={partida}
isExpanded={expandedPartidas.has(partida.id)}
onToggle={() => togglePartida(partida.id)}
/>
))}
</tbody>
</table>
</div>
)}
</div>
{estimacion.observaciones && (
<div className="bg-yellow-50 p-4 rounded-lg">
<p className="text-sm font-medium text-yellow-800">Observaciones</p>
<p className="text-yellow-700">{estimacion.observaciones}</p>
</div>
)}
</div>
)}
</div>
</div>
);
}
interface PartidaRowProps {
partida: EstimacionPartida;
isExpanded: boolean;
onToggle: () => void;
}
function PartidaRow({ partida, isExpanded, onToggle }: PartidaRowProps) {
const hasGeneradores = partida.generadores && partida.generadores.length > 0;
return (
<>
<tr className="hover:bg-gray-50">
<td className="px-4 py-2">
{hasGeneradores && (
<button onClick={onToggle} className="p-1 hover:bg-gray-200 rounded">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
)}
</td>
<td className="px-4 py-2 text-sm">
<span className="font-mono text-gray-500 mr-2">{partida.concepto?.codigo}</span>
{partida.concepto?.descripcion}
</td>
<td className="px-4 py-2 text-sm text-right">
{partida.cantidadEstimada.toLocaleString('es-MX')} {partida.concepto?.unidad}
</td>
<td className="px-4 py-2 text-sm text-right">
${partida.precioUnitario.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</td>
<td className="px-4 py-2 text-sm text-right font-medium">
${partida.importeEstimado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</td>
</tr>
{isExpanded && hasGeneradores && (
<tr>
<td colSpan={5} className="px-4 py-2 bg-gray-50">
<div className="pl-8">
<p className="text-xs font-medium text-gray-500 mb-2">Generadores:</p>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500">
<th className="text-left py-1">Descripcion</th>
<th className="text-right py-1">Largo</th>
<th className="text-right py-1">Ancho</th>
<th className="text-right py-1">Alto</th>
<th className="text-right py-1">Cantidad</th>
<th className="text-right py-1">Parcial</th>
</tr>
</thead>
<tbody>
{partida.generadores!.map((gen) => (
<tr key={gen.id} className="border-t border-gray-200">
<td className="py-1">{gen.descripcion}</td>
<td className="text-right py-1">{gen.largo || '-'}</td>
<td className="text-right py-1">{gen.ancho || '-'}</td>
<td className="text-right py-1">{gen.alto || '-'}</td>
<td className="text-right py-1">{gen.cantidad}</td>
<td className="text-right py-1 font-medium">{gen.parcial.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
)}
</>
);
}

View File

@ -0,0 +1,409 @@
import { useState } from 'react';
import {
Plus,
Pencil,
Trash2,
Search,
Copy,
CheckCircle,
Eye,
} from 'lucide-react';
import {
usePresupuestos,
useCreatePresupuesto,
useUpdatePresupuesto,
useDeletePresupuesto,
useApprovePresupuesto,
useDuplicatePresupuesto,
} from '../../../hooks/usePresupuestos';
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',
};
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 { data, isLoading, error } = usePresupuestos({
estado: estadoFilter || undefined,
});
const deleteMutation = useDeletePresupuesto();
const createMutation = useCreatePresupuesto();
const updateMutation = useUpdatePresupuesto();
const approveMutation = useApprovePresupuesto();
const duplicateMutation = useDuplicatePresupuesto();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const handleApprove = async (id: string) => {
await approveMutation.mutateAsync(id);
setApproveConfirm(null);
};
const handleDuplicate = async (id: string) => {
await duplicateMutation.mutateAsync(id);
};
const handleSubmit = async (formData: CreatePresupuestoDto) => {
if (editingItem) {
await updateMutation.mutateAsync({
id: editingItem.id,
data: {
codigo: formData.codigo,
nombre: formData.nombre,
estado: formData.estado,
},
});
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
const presupuestos = (data?.items || []).filter(
(p) =>
!search ||
p.codigo.toLowerCase().includes(search.toLowerCase()) ||
p.nombre.toLowerCase().includes(search.toLowerCase())
);
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>
<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"
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>
</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">
<button
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" />
</button>
{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>
{showModal && (
<PresupuestoModal
item={editingItem}
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>
)}
{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>
)}
</div>
);
}
interface PresupuestoModalProps {
item: Presupuesto | null;
onClose: () => void;
onSubmit: (data: CreatePresupuestoDto) => Promise<void>;
isLoading: boolean;
}
function PresupuestoModal({ item, onClose, onSubmit, isLoading }: PresupuestoModalProps) {
const [formData, setFormData] = useState<CreatePresupuestoDto>({
codigo: item?.codigo || '',
nombre: item?.nombre || '',
proyectoId: item?.proyectoId || 'default-project-id',
estado: item?.estado || 'borrador',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const editableStates: PresupuestoEstado[] = ['borrador', 'revision'];
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>
);
}

View File

@ -0,0 +1,3 @@
export { ConceptosPage } from './ConceptosPage';
export { PresupuestosPage } from './PresupuestosPage';
export { EstimacionesPage } from './EstimacionesPage';

View File

@ -0,0 +1,334 @@
import api, { PaginatedResponse, PaginationParams } from '../api';
// ============================================================================
// STATUS TYPES
// ============================================================================
export type OpportunityStatus = 'lead' | 'qualified' | 'proposal' | 'negotiation' | 'won' | 'lost';
export type TenderStatus = 'draft' | 'published' | 'evaluation' | 'awarded' | 'cancelled';
export type ProposalStatus = 'draft' | 'submitted' | 'under_review' | 'accepted' | 'rejected';
// ============================================================================
// ENTITY INTERFACES
// ============================================================================
export interface Opportunity {
id: string;
tenantId: string;
title: string;
description?: string;
clientName: string;
estimatedValue: number;
probability: number;
status: OpportunityStatus;
expectedCloseDate?: string;
source?: string;
createdAt: string;
updatedAt: string;
}
export interface Tender {
id: string;
tenantId: string;
opportunityId?: string;
referenceNumber: string;
title: string;
description?: string;
issuingEntity: string;
estimatedBudget?: number;
status: TenderStatus;
publicationDate?: string;
submissionDeadline: string;
openingDate?: string;
createdAt: string;
updatedAt: string;
}
export interface Proposal {
id: string;
tenantId: string;
tenderId: string;
tender?: Tender;
proposalNumber: string;
technicalScore?: number;
economicScore?: number;
totalAmount: number;
status: ProposalStatus;
submittedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface Vendor {
id: string;
tenantId: string;
businessName: string;
rfc?: string;
contactName?: string;
email?: string;
phone?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// ============================================================================
// FILTER INTERFACES
// ============================================================================
export interface OpportunityFilters extends PaginationParams {
status?: OpportunityStatus;
clientName?: string;
minValue?: number;
maxValue?: number;
}
export interface TenderFilters extends PaginationParams {
status?: TenderStatus;
opportunityId?: string;
issuingEntity?: string;
}
export interface ProposalFilters extends PaginationParams {
status?: ProposalStatus;
tenderId?: string;
}
export interface VendorFilters extends PaginationParams {
isActive?: boolean;
businessName?: string;
}
// ============================================================================
// DTO INTERFACES
// ============================================================================
export interface CreateOpportunityDto {
title: string;
clientName: string;
estimatedValue: number;
probability: number;
description?: string;
status?: OpportunityStatus;
expectedCloseDate?: string;
source?: string;
}
export interface UpdateOpportunityDto {
title?: string;
description?: string;
clientName?: string;
estimatedValue?: number;
probability?: number;
status?: OpportunityStatus;
expectedCloseDate?: string;
source?: string;
}
export interface CreateTenderDto {
referenceNumber: string;
title: string;
issuingEntity: string;
submissionDeadline: string;
opportunityId?: string;
description?: string;
estimatedBudget?: number;
status?: TenderStatus;
publicationDate?: string;
openingDate?: string;
}
export interface UpdateTenderDto {
referenceNumber?: string;
title?: string;
description?: string;
issuingEntity?: string;
estimatedBudget?: number;
status?: TenderStatus;
publicationDate?: string;
submissionDeadline?: string;
openingDate?: string;
}
export interface CreateProposalDto {
tenderId: string;
proposalNumber: string;
totalAmount: number;
technicalScore?: number;
economicScore?: number;
status?: ProposalStatus;
}
export interface UpdateProposalDto {
proposalNumber?: string;
technicalScore?: number;
economicScore?: number;
totalAmount?: number;
status?: ProposalStatus;
}
export interface CreateVendorDto {
businessName: string;
rfc?: string;
contactName?: string;
email?: string;
phone?: string;
isActive?: boolean;
}
export interface UpdateVendorDto {
businessName?: string;
rfc?: string;
contactName?: string;
email?: string;
phone?: string;
isActive?: boolean;
}
// ============================================================================
// OPPORTUNITIES API
// ============================================================================
export const opportunitiesApi = {
list: async (filters?: OpportunityFilters): Promise<PaginatedResponse<Opportunity>> => {
const response = await api.get<PaginatedResponse<Opportunity>>('/bidding/opportunities', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Opportunity> => {
const response = await api.get<Opportunity>(`/bidding/opportunities/${id}`);
return response.data;
},
create: async (data: CreateOpportunityDto): Promise<Opportunity> => {
const response = await api.post<Opportunity>('/bidding/opportunities', data);
return response.data;
},
update: async (id: string, data: UpdateOpportunityDto): Promise<Opportunity> => {
const response = await api.patch<Opportunity>(`/bidding/opportunities/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/bidding/opportunities/${id}`);
},
updateStatus: async (id: string, status: OpportunityStatus): Promise<Opportunity> => {
const response = await api.patch<Opportunity>(`/bidding/opportunities/${id}/status`, { status });
return response.data;
},
};
// ============================================================================
// TENDERS API
// ============================================================================
export const tendersApi = {
list: async (filters?: TenderFilters): Promise<PaginatedResponse<Tender>> => {
const response = await api.get<PaginatedResponse<Tender>>('/bidding/tenders', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Tender> => {
const response = await api.get<Tender>(`/bidding/tenders/${id}`);
return response.data;
},
create: async (data: CreateTenderDto): Promise<Tender> => {
const response = await api.post<Tender>('/bidding/tenders', data);
return response.data;
},
update: async (id: string, data: UpdateTenderDto): Promise<Tender> => {
const response = await api.patch<Tender>(`/bidding/tenders/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/bidding/tenders/${id}`);
},
updateStatus: async (id: string, status: TenderStatus): Promise<Tender> => {
const response = await api.patch<Tender>(`/bidding/tenders/${id}/status`, { status });
return response.data;
},
};
// ============================================================================
// PROPOSALS API
// ============================================================================
export const proposalsApi = {
list: async (filters?: ProposalFilters): Promise<PaginatedResponse<Proposal>> => {
const response = await api.get<PaginatedResponse<Proposal>>('/bidding/proposals', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Proposal> => {
const response = await api.get<Proposal>(`/bidding/proposals/${id}`);
return response.data;
},
create: async (data: CreateProposalDto): Promise<Proposal> => {
const response = await api.post<Proposal>('/bidding/proposals', data);
return response.data;
},
update: async (id: string, data: UpdateProposalDto): Promise<Proposal> => {
const response = await api.patch<Proposal>(`/bidding/proposals/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/bidding/proposals/${id}`);
},
submit: async (id: string): Promise<Proposal> => {
const response = await api.post<Proposal>(`/bidding/proposals/${id}/submit`);
return response.data;
},
};
// ============================================================================
// VENDORS API
// ============================================================================
export const vendorsApi = {
list: async (filters?: VendorFilters): Promise<PaginatedResponse<Vendor>> => {
const response = await api.get<PaginatedResponse<Vendor>>('/bidding/vendors', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Vendor> => {
const response = await api.get<Vendor>(`/bidding/vendors/${id}`);
return response.data;
},
create: async (data: CreateVendorDto): Promise<Vendor> => {
const response = await api.post<Vendor>('/bidding/vendors', data);
return response.data;
},
update: async (id: string, data: UpdateVendorDto): Promise<Vendor> => {
const response = await api.patch<Vendor>(`/bidding/vendors/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/bidding/vendors/${id}`);
},
toggleActive: async (id: string): Promise<Vendor> => {
const response = await api.patch<Vendor>(`/bidding/vendors/${id}/toggle-active`);
return response.data;
},
};

View File

@ -0,0 +1 @@
export * from './bidding.api';

View File

@ -0,0 +1,259 @@
import api, { PaginatedResponse, PaginationParams } from '../api';
import { Concepto } from './presupuestos.api';
// ===========================
// TIPOS PARA 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;
}
// ===========================
// API DE ESTIMACIONES
// ===========================
export const estimacionesApi = {
list: async (filters?: EstimacionFilters): Promise<PaginatedResponse<Estimacion>> => {
const response = await api.get<PaginatedResponse<Estimacion>>('/estimaciones', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Estimacion> => {
const response = await api.get<Estimacion>(`/estimaciones/${id}`);
return response.data;
},
create: async (data: CreateEstimacionDto): Promise<Estimacion> => {
const response = await api.post<Estimacion>('/estimaciones', data);
return response.data;
},
update: async (id: string, data: UpdateEstimacionDto): Promise<Estimacion> => {
const response = await api.patch<Estimacion>(`/estimaciones/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/estimaciones/${id}`);
},
// Workflow actions
submit: async (id: string): Promise<Estimacion> => {
const response = await api.post<Estimacion>(`/estimaciones/${id}/submit`);
return response.data;
},
approve: async (id: string, montoAprobado?: number): Promise<Estimacion> => {
const response = await api.post<Estimacion>(`/estimaciones/${id}/approve`, {
montoAprobado,
});
return response.data;
},
reject: async (id: string, motivo: string): Promise<Estimacion> => {
const response = await api.post<Estimacion>(`/estimaciones/${id}/reject`, {
motivo,
});
return response.data;
},
markAsInvoiced: async (id: string, montoFacturado: number): Promise<Estimacion> => {
const response = await api.post<Estimacion>(`/estimaciones/${id}/invoice`, {
montoFacturado,
});
return response.data;
},
markAsPaid: async (id: string, montoCobrado: number): Promise<Estimacion> => {
const response = await api.post<Estimacion>(`/estimaciones/${id}/pay`, {
montoCobrado,
});
return response.data;
},
// Partidas
addPartida: async (estimacionId: string, data: CreateEstimacionPartidaDto): Promise<EstimacionPartida> => {
const response = await api.post<EstimacionPartida>(
`/estimaciones/${estimacionId}/partidas`,
data
);
return response.data;
},
updatePartida: async (
estimacionId: string,
partidaId: string,
data: UpdateEstimacionPartidaDto
): Promise<EstimacionPartida> => {
const response = await api.patch<EstimacionPartida>(
`/estimaciones/${estimacionId}/partidas/${partidaId}`,
data
);
return response.data;
},
removePartida: async (estimacionId: string, partidaId: string): Promise<void> => {
await api.delete(`/estimaciones/${estimacionId}/partidas/${partidaId}`);
},
// Generadores
addGenerador: async (
estimacionId: string,
partidaId: string,
data: CreateGeneradorDto
): Promise<Generador> => {
const response = await api.post<Generador>(
`/estimaciones/${estimacionId}/partidas/${partidaId}/generadores`,
data
);
return response.data;
},
updateGenerador: async (
estimacionId: string,
partidaId: string,
generadorId: string,
data: UpdateGeneradorDto
): Promise<Generador> => {
const response = await api.patch<Generador>(
`/estimaciones/${estimacionId}/partidas/${partidaId}/generadores/${generadorId}`,
data
);
return response.data;
},
removeGenerador: async (
estimacionId: string,
partidaId: string,
generadorId: string
): Promise<void> => {
await api.delete(
`/estimaciones/${estimacionId}/partidas/${partidaId}/generadores/${generadorId}`
);
},
};

View File

@ -0,0 +1,2 @@
export * from './presupuestos.api';
export * from './estimaciones.api';

View File

@ -0,0 +1,178 @@
import api, { PaginatedResponse, PaginationParams } from '../api';
// ===========================
// TIPOS PARA 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;
}
// ===========================
// TIPOS PARA 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;
}
// ===========================
// API DE CONCEPTOS
// ===========================
export const conceptosApi = {
list: async (filters?: ConceptoFilters): Promise<PaginatedResponse<Concepto>> => {
const response = await api.get<PaginatedResponse<Concepto>>('/conceptos', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Concepto> => {
const response = await api.get<Concepto>(`/conceptos/${id}`);
return response.data;
},
getTree: async (rootId?: string): Promise<Concepto[]> => {
const response = await api.get<Concepto[]>('/conceptos/tree', {
params: rootId ? { rootId } : undefined,
});
return response.data;
},
create: async (data: CreateConceptoDto): Promise<Concepto> => {
const response = await api.post<Concepto>('/conceptos', data);
return response.data;
},
update: async (id: string, data: UpdateConceptoDto): Promise<Concepto> => {
const response = await api.patch<Concepto>(`/conceptos/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/conceptos/${id}`);
},
};
// ===========================
// API DE PRESUPUESTOS
// ===========================
export const presupuestosApi = {
list: async (filters?: PresupuestoFilters): Promise<PaginatedResponse<Presupuesto>> => {
const response = await api.get<PaginatedResponse<Presupuesto>>('/presupuestos', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Presupuesto> => {
const response = await api.get<Presupuesto>(`/presupuestos/${id}`);
return response.data;
},
create: async (data: CreatePresupuestoDto): Promise<Presupuesto> => {
const response = await api.post<Presupuesto>('/presupuestos', data);
return response.data;
},
update: async (id: string, data: UpdatePresupuestoDto): Promise<Presupuesto> => {
const response = await api.patch<Presupuesto>(`/presupuestos/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/presupuestos/${id}`);
},
approve: async (id: string): Promise<Presupuesto> => {
const response = await api.post<Presupuesto>(`/presupuestos/${id}/approve`);
return response.data;
},
duplicate: async (id: string): Promise<Presupuesto> => {
const response = await api.post<Presupuesto>(`/presupuestos/${id}/duplicate`);
return response.data;
},
};

View File

@ -0,0 +1,14 @@
export {
reportsApi,
type EarnedValueStatus,
type EarnedValueMetrics,
type SCurveDataPoint,
type ProjectSummary,
type DashboardStats,
type AlertSeverity,
type AlertType,
type Alert,
type DateRangeParams,
type ProjectSummaryFilters,
type AlertFilters,
} from './reports.api';

View File

@ -0,0 +1,209 @@
import api, { PaginatedResponse, PaginationParams } from '../api';
// =============================================================================
// Types - Earned Value Management
// =============================================================================
export type EarnedValueStatus = 'green' | 'yellow' | 'red';
export interface EarnedValueMetrics {
date: string;
bac: number; // Budget at Completion
pv: number; // Planned Value
ev: number; // Earned Value
ac: number; // Actual Cost
spi: number; // Schedule Performance Index
cpi: number; // Cost Performance Index
sv: number; // Schedule Variance
cv: number; // Cost Variance
eac: number; // Estimate at Completion
etc: number; // Estimate to Complete
vac: number; // Variance at Completion
tcpi: number; // To Complete Performance Index
percentComplete: number;
status: EarnedValueStatus;
}
export interface SCurveDataPoint {
date: string;
plannedValue: number;
earnedValue: number;
actualCost: number;
plannedCumulative: number;
earnedCumulative: number;
actualCumulative: number;
}
// =============================================================================
// Types - Dashboard
// =============================================================================
export interface ProjectSummary {
id: string;
nombre: string;
presupuesto: number;
avanceReal: number;
avanceProgramado: number;
spi: number;
cpi: number;
status: EarnedValueStatus;
}
export interface DashboardStats {
totalProyectos: number;
proyectosActivos: number;
presupuestoTotal: number;
avancePromedio: number;
alertasActivas: number;
}
// =============================================================================
// Types - Alerts
// =============================================================================
export type AlertSeverity = 'critical' | 'warning' | 'info';
export type AlertType = 'schedule' | 'cost' | 'quality' | 'safety' | 'general';
export interface Alert {
id: string;
projectId: string;
projectName: string;
type: AlertType;
severity: AlertSeverity;
title: string;
message: string;
metric?: string;
value?: number;
threshold?: number;
createdAt: string;
acknowledgedAt?: string;
resolvedAt?: string;
}
// =============================================================================
// Types - Filters and Params
// =============================================================================
export interface DateRangeParams {
startDate?: string;
endDate?: string;
}
export interface ProjectSummaryFilters extends PaginationParams {
status?: EarnedValueStatus;
}
export interface AlertFilters extends PaginationParams {
severity?: AlertSeverity;
projectId?: string;
type?: AlertType;
acknowledged?: boolean;
}
// =============================================================================
// API
// =============================================================================
export const reportsApi = {
/**
* Get Earned Value metrics for a specific project
* Returns SPI, CPI, EV, PV, AC, and other EVM indicators
*/
getEarnedValue: async (
projectId: string,
params?: DateRangeParams
): Promise<EarnedValueMetrics> => {
const response = await api.get<EarnedValueMetrics>(
`/reports/projects/${projectId}/earned-value`,
{ params }
);
return response.data;
},
/**
* Get S-Curve data for a specific project
* Returns time series data for planned vs actual progress
*/
getSCurveData: async (
projectId: string,
params?: DateRangeParams
): Promise<SCurveDataPoint[]> => {
const response = await api.get<SCurveDataPoint[]>(
`/reports/projects/${projectId}/s-curve`,
{ params }
);
return response.data;
},
/**
* Get summary of all projects with their KPIs
* Supports filtering by status and search
*/
getProjectsSummary: async (
filters?: ProjectSummaryFilters
): Promise<PaginatedResponse<ProjectSummary>> => {
const response = await api.get<PaginatedResponse<ProjectSummary>>(
'/reports/projects/summary',
{ params: filters }
);
return response.data;
},
/**
* Get general dashboard statistics
* Returns aggregate metrics for all projects
*/
getDashboardStats: async (): Promise<DashboardStats> => {
const response = await api.get<DashboardStats>('/reports/dashboard/stats');
return response.data;
},
/**
* Get KPIs for a specific project
* Alias for getEarnedValue for semantic clarity
*/
getProjectKPIs: async (
projectId: string,
params?: DateRangeParams
): Promise<EarnedValueMetrics> => {
const response = await api.get<EarnedValueMetrics>(
`/reports/projects/${projectId}/kpis`,
{ params }
);
return response.data;
},
/**
* Get active alerts
* Supports filtering by severity and project
*/
getAlerts: async (
filters?: AlertFilters
): Promise<PaginatedResponse<Alert>> => {
const response = await api.get<PaginatedResponse<Alert>>(
'/reports/alerts',
{ params: filters }
);
return response.data;
},
/**
* Acknowledge an alert
*/
acknowledgeAlert: async (alertId: string): Promise<Alert> => {
const response = await api.patch<Alert>(
`/reports/alerts/${alertId}/acknowledge`
);
return response.data;
},
/**
* Resolve an alert
*/
resolveAlert: async (alertId: string): Promise<Alert> => {
const response = await api.patch<Alert>(
`/reports/alerts/${alertId}/resolve`
);
return response.data;
},
};