From e4cfe62b1b676a19c18a41669decd1e884676f63 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 27 Jan 2026 07:21:28 -0600 Subject: [PATCH] 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 --- web/src/App.tsx | 31 +- web/src/hooks/index.ts | 3 + web/src/hooks/useBidding.ts | 343 +++++++ web/src/hooks/usePresupuestos.ts | 430 +++++++++ web/src/hooks/useReports.ts | 154 ++++ web/src/layouts/AdminLayout.tsx | 154 +++- .../pages/admin/bidding/OpportunitiesPage.tsx | 451 ++++++++++ web/src/pages/admin/bidding/ProposalsPage.tsx | 451 ++++++++++ web/src/pages/admin/bidding/TendersPage.tsx | 451 ++++++++++ web/src/pages/admin/bidding/VendorsPage.tsx | 370 ++++++++ web/src/pages/admin/bidding/index.ts | 4 + .../pages/admin/dashboard/DashboardPage.tsx | 709 +++++++++++++++ web/src/pages/admin/dashboard/index.ts | 1 + .../admin/presupuestos/ConceptosPage.tsx | 547 ++++++++++++ .../admin/presupuestos/EstimacionesPage.tsx | 837 ++++++++++++++++++ .../admin/presupuestos/PresupuestosPage.tsx | 409 +++++++++ web/src/pages/admin/presupuestos/index.ts | 3 + web/src/services/bidding/bidding.api.ts | 334 +++++++ web/src/services/bidding/index.ts | 1 + .../services/presupuestos/estimaciones.api.ts | 259 ++++++ web/src/services/presupuestos/index.ts | 2 + .../services/presupuestos/presupuestos.api.ts | 178 ++++ web/src/services/reports/index.ts | 14 + web/src/services/reports/reports.api.ts | 209 +++++ 24 files changed, 6308 insertions(+), 37 deletions(-) create mode 100644 web/src/hooks/useBidding.ts create mode 100644 web/src/hooks/usePresupuestos.ts create mode 100644 web/src/hooks/useReports.ts create mode 100644 web/src/pages/admin/bidding/OpportunitiesPage.tsx create mode 100644 web/src/pages/admin/bidding/ProposalsPage.tsx create mode 100644 web/src/pages/admin/bidding/TendersPage.tsx create mode 100644 web/src/pages/admin/bidding/VendorsPage.tsx create mode 100644 web/src/pages/admin/bidding/index.ts create mode 100644 web/src/pages/admin/dashboard/DashboardPage.tsx create mode 100644 web/src/pages/admin/dashboard/index.ts create mode 100644 web/src/pages/admin/presupuestos/ConceptosPage.tsx create mode 100644 web/src/pages/admin/presupuestos/EstimacionesPage.tsx create mode 100644 web/src/pages/admin/presupuestos/PresupuestosPage.tsx create mode 100644 web/src/pages/admin/presupuestos/index.ts create mode 100644 web/src/services/bidding/bidding.api.ts create mode 100644 web/src/services/bidding/index.ts create mode 100644 web/src/services/presupuestos/estimaciones.api.ts create mode 100644 web/src/services/presupuestos/index.ts create mode 100644 web/src/services/presupuestos/presupuestos.api.ts create mode 100644 web/src/services/reports/index.ts create mode 100644 web/src/services/reports/reports.api.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 9bbf979..fa0f54e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 (
- {/* Ruta principal - redirect to admin */} - } /> + {/* Ruta principal - redirect to admin dashboard */} + } /> {/* Portal Admin */} }> - } /> + } /> + + {/* Dashboard */} + } /> + + {/* Proyectos */} } /> } /> @@ -34,6 +42,23 @@ function App() { } /> } /> + + {/* Presupuestos */} + + } /> + } /> + } /> + } /> + + + {/* Licitaciones */} + + } /> + } /> + } /> + } /> + } /> + {/* Portal Supervisor */} diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index 0537b19..a1f8073 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -1 +1,4 @@ export * from './useConstruccion'; +export * from './usePresupuestos'; +export * from './useReports'; +export * from './useBidding'; diff --git a/web/src/hooks/useBidding.ts b/web/src/hooks/useBidding.ts new file mode 100644 index 0000000..53d6e3d --- /dev/null +++ b/web/src/hooks/useBidding.ts @@ -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) => { + 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, + }); +} diff --git a/web/src/hooks/usePresupuestos.ts b/web/src/hooks/usePresupuestos.ts new file mode 100644 index 0000000..9c6e747 --- /dev/null +++ b/web/src/hooks/usePresupuestos.ts @@ -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) => { + 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, + }); +} diff --git a/web/src/hooks/useReports.ts b/web/src/hooks/useReports.ts new file mode 100644 index 0000000..1865f70 --- /dev/null +++ b/web/src/hooks/useReports.ts @@ -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) => { + 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, + }); +} diff --git a/web/src/layouts/AdminLayout.tsx b/web/src/layouts/AdminLayout.tsx index 8177453..3d0703c 100644 --- a/web/src/layouts/AdminLayout.tsx +++ b/web/src/layouts/AdminLayout.tsx @@ -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>(() => { + const initial: Record = {}; + 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 (
{/* Mobile sidebar backdrop */} @@ -70,34 +135,55 @@ export function AdminLayout() {
-