[SPRINT-3] feat: Presupuesto detail page with version management

- Add PresupuestoDetailPage with partidas table
- Add version history sidebar
- Add partida CRUD operations
- Add approval/rejection workflow
- Add PDF/Excel export buttons
- Enhance presupuestos.api.ts with version management APIs
- Add 8 new hooks in usePresupuestos.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 01:15:19 -06:00
parent 5083292fbd
commit 0d0d52ac29
6 changed files with 1410 additions and 3 deletions

View File

@ -14,7 +14,7 @@ import {
} 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 { ConceptosPage, PresupuestosPage, PresupuestoDetailPage, EstimacionesPage } from './pages/admin/presupuestos';
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
import { IncidentesPage, CapacitacionesPage, InspeccionesPage, InspeccionDetailPage } from './pages/admin/hse';
import { AvancesObraPage, BitacoraObraPage, ProgramaObraPage, ControlAvancePage } from './pages/admin/obras';
@ -50,6 +50,7 @@ function App() {
<Route index element={<Navigate to="catalogo" replace />} />
<Route path="catalogo" element={<ConceptosPage />} />
<Route path="presupuestos" element={<PresupuestosPage />} />
<Route path="presupuestos/:id" element={<PresupuestoDetailPage />} />
<Route path="estimaciones" element={<EstimacionesPage />} />
</Route>

View File

@ -11,6 +11,9 @@ import {
PresupuestoFilters,
CreatePresupuestoDto,
UpdatePresupuestoDto,
CreatePresupuestoPartidaDto,
UpdatePresupuestoPartidaDto,
RejectPresupuestoDto,
estimacionesApi,
EstimacionFilters,
CreateEstimacionDto,
@ -191,6 +194,132 @@ export function useDuplicatePresupuesto() {
});
}
export function usePresupuestoWithPartidas(id: string) {
return useQuery({
queryKey: [...presupuestosKeys.presupuestos.detail(id), 'partidas'],
queryFn: () => presupuestosApi.getWithPartidas(id),
enabled: !!id,
});
}
export function usePresupuestoVersions(id: string) {
return useQuery({
queryKey: [...presupuestosKeys.presupuestos.detail(id), 'versions'],
queryFn: () => presupuestosApi.getVersions(id),
enabled: !!id,
});
}
export function useCreatePresupuestoVersion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, notas }: { id: string; notas?: string }) =>
presupuestosApi.createVersion(id, notas),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(id) });
toast.success('Nueva version creada exitosamente');
},
onError: handleError,
});
}
export function useRejectPresupuesto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: RejectPresupuestoDto }) =>
presupuestosApi.reject(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(id) });
toast.success('Presupuesto rechazado');
},
onError: handleError,
});
}
export function useAddPresupuestoPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ presupuestoId, data }: { presupuestoId: string; data: CreatePresupuestoPartidaDto }) =>
presupuestosApi.addPartida(presupuestoId, data),
onSuccess: (_, { presupuestoId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(presupuestoId) });
toast.success('Partida agregada al presupuesto');
},
onError: handleError,
});
}
export function useUpdatePresupuestoPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
presupuestoId,
partidaId,
data,
}: {
presupuestoId: string;
partidaId: string;
data: UpdatePresupuestoPartidaDto;
}) => presupuestosApi.updatePartida(presupuestoId, partidaId, data),
onSuccess: (_, { presupuestoId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(presupuestoId) });
toast.success('Partida actualizada');
},
onError: handleError,
});
}
export function useDeletePresupuestoPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ presupuestoId, partidaId }: { presupuestoId: string; partidaId: string }) =>
presupuestosApi.deletePartida(presupuestoId, partidaId),
onSuccess: (_, { presupuestoId }) => {
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(presupuestoId) });
toast.success('Partida eliminada del presupuesto');
},
onError: handleError,
});
}
export function useExportPresupuestoPdf() {
return useMutation({
mutationFn: (id: string) => presupuestosApi.exportPdf(id),
onSuccess: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `presupuesto-${Date.now()}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('PDF exportado exitosamente');
},
onError: handleError,
});
}
export function useExportPresupuestoExcel() {
return useMutation({
mutationFn: (id: string) => presupuestosApi.exportExcel(id),
onSuccess: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `presupuesto-${Date.now()}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Excel exportado exitosamente');
},
onError: handleError,
});
}
// ==================== ESTIMACIONES ====================
export function useEstimaciones(filters?: EstimacionFilters) {

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Plus,
Pencil,
@ -197,12 +198,13 @@ export function PresupuestosPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
<button
<Link
to={`/admin/presupuestos/presupuestos/${item.id}`}
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>
</Link>
{item.estado === 'revision' && (
<button
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"

View File

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

View File

@ -139,6 +139,50 @@ export const conceptosApi = {
// API DE PRESUPUESTOS
// ===========================
// ===========================
// TIPOS PARA PARTIDAS DE PRESUPUESTO
// ===========================
export interface CreatePresupuestoPartidaDto {
conceptoId: string;
cantidad: number;
precioUnitario: number;
orden?: number;
}
export interface UpdatePresupuestoPartidaDto {
cantidad?: number;
precioUnitario?: number;
orden?: number;
}
// ===========================
// TIPOS PARA VERSIONES
// ===========================
export interface PresupuestoVersion {
id: string;
presupuestoId: string;
version: number;
estado: PresupuestoEstado;
montoTotal: number;
fechaCreacion: string;
creadoPor?: string;
notas?: string;
}
// ===========================
// TIPOS PARA APROBACION/RECHAZO
// ===========================
export interface RejectPresupuestoDto {
motivo: string;
}
// ===========================
// API DE PRESUPUESTOS
// ===========================
export const presupuestosApi = {
list: async (filters?: PresupuestoFilters): Promise<PaginatedResponse<Presupuesto>> => {
const response = await api.get<PaginatedResponse<Presupuesto>>('/presupuestos', {
@ -152,6 +196,11 @@ export const presupuestosApi = {
return response.data;
},
getWithPartidas: async (id: string): Promise<Presupuesto> => {
const response = await api.get<Presupuesto>(`/presupuestos/${id}?include=partidas,partidas.concepto`);
return response.data;
},
create: async (data: CreatePresupuestoDto): Promise<Presupuesto> => {
const response = await api.post<Presupuesto>('/presupuestos', data);
return response.data;
@ -171,8 +220,59 @@ export const presupuestosApi = {
return response.data;
},
reject: async (id: string, data: RejectPresupuestoDto): Promise<Presupuesto> => {
const response = await api.post<Presupuesto>(`/presupuestos/${id}/reject`, data);
return response.data;
},
duplicate: async (id: string): Promise<Presupuesto> => {
const response = await api.post<Presupuesto>(`/presupuestos/${id}/duplicate`);
return response.data;
},
createVersion: async (id: string, notas?: string): Promise<Presupuesto> => {
const response = await api.post<Presupuesto>(`/presupuestos/${id}/version`, { notas });
return response.data;
},
getVersions: async (id: string): Promise<PresupuestoVersion[]> => {
const response = await api.get<PresupuestoVersion[]>(`/presupuestos/${id}/versions`);
return response.data;
},
exportPdf: async (id: string): Promise<Blob> => {
const response = await api.get(`/presupuestos/${id}/export/pdf`, {
responseType: 'blob',
});
return response.data;
},
exportExcel: async (id: string): Promise<Blob> => {
const response = await api.get(`/presupuestos/${id}/export/excel`, {
responseType: 'blob',
});
return response.data;
},
// Partidas
addPartida: async (id: string, data: CreatePresupuestoPartidaDto): Promise<PresupuestoPartida> => {
const response = await api.post<PresupuestoPartida>(`/presupuestos/${id}/partidas`, data);
return response.data;
},
updatePartida: async (
presupuestoId: string,
partidaId: string,
data: UpdatePresupuestoPartidaDto
): Promise<PresupuestoPartida> => {
const response = await api.patch<PresupuestoPartida>(
`/presupuestos/${presupuestoId}/partidas/${partidaId}`,
data
);
return response.data;
},
deletePartida: async (presupuestoId: string, partidaId: string): Promise<void> => {
await api.delete(`/presupuestos/${presupuestoId}/partidas/${partidaId}`);
},
};