[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:
parent
5083292fbd
commit
0d0d52ac29
@ -14,7 +14,7 @@ import {
|
|||||||
} from './pages/admin/proyectos';
|
} from './pages/admin/proyectos';
|
||||||
import { ManzanasPage } from './pages/admin/proyectos/ManzanasPage';
|
import { ManzanasPage } from './pages/admin/proyectos/ManzanasPage';
|
||||||
import { DashboardPage } from './pages/admin/dashboard';
|
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 { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
|
||||||
import { IncidentesPage, CapacitacionesPage, InspeccionesPage, InspeccionDetailPage } from './pages/admin/hse';
|
import { IncidentesPage, CapacitacionesPage, InspeccionesPage, InspeccionDetailPage } from './pages/admin/hse';
|
||||||
import { AvancesObraPage, BitacoraObraPage, ProgramaObraPage, ControlAvancePage } from './pages/admin/obras';
|
import { AvancesObraPage, BitacoraObraPage, ProgramaObraPage, ControlAvancePage } from './pages/admin/obras';
|
||||||
@ -50,6 +50,7 @@ function App() {
|
|||||||
<Route index element={<Navigate to="catalogo" replace />} />
|
<Route index element={<Navigate to="catalogo" replace />} />
|
||||||
<Route path="catalogo" element={<ConceptosPage />} />
|
<Route path="catalogo" element={<ConceptosPage />} />
|
||||||
<Route path="presupuestos" element={<PresupuestosPage />} />
|
<Route path="presupuestos" element={<PresupuestosPage />} />
|
||||||
|
<Route path="presupuestos/:id" element={<PresupuestoDetailPage />} />
|
||||||
<Route path="estimaciones" element={<EstimacionesPage />} />
|
<Route path="estimaciones" element={<EstimacionesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import {
|
|||||||
PresupuestoFilters,
|
PresupuestoFilters,
|
||||||
CreatePresupuestoDto,
|
CreatePresupuestoDto,
|
||||||
UpdatePresupuestoDto,
|
UpdatePresupuestoDto,
|
||||||
|
CreatePresupuestoPartidaDto,
|
||||||
|
UpdatePresupuestoPartidaDto,
|
||||||
|
RejectPresupuestoDto,
|
||||||
estimacionesApi,
|
estimacionesApi,
|
||||||
EstimacionFilters,
|
EstimacionFilters,
|
||||||
CreateEstimacionDto,
|
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 ====================
|
// ==================== ESTIMACIONES ====================
|
||||||
|
|
||||||
export function useEstimaciones(filters?: EstimacionFilters) {
|
export function useEstimaciones(filters?: EstimacionFilters) {
|
||||||
|
|||||||
1174
web/src/pages/admin/presupuestos/PresupuestoDetailPage.tsx
Normal file
1174
web/src/pages/admin/presupuestos/PresupuestoDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@ -197,12 +198,13 @@ export function PresupuestosPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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"
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
title="Ver detalle"
|
title="Ver detalle"
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</button>
|
</Link>
|
||||||
{item.estado === 'revision' && (
|
{item.estado === 'revision' && (
|
||||||
<button
|
<button
|
||||||
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export { ConceptosPage } from './ConceptosPage';
|
export { ConceptosPage } from './ConceptosPage';
|
||||||
export { PresupuestosPage } from './PresupuestosPage';
|
export { PresupuestosPage } from './PresupuestosPage';
|
||||||
|
export { PresupuestoDetailPage } from './PresupuestoDetailPage';
|
||||||
export { EstimacionesPage } from './EstimacionesPage';
|
export { EstimacionesPage } from './EstimacionesPage';
|
||||||
|
|||||||
@ -139,6 +139,50 @@ export const conceptosApi = {
|
|||||||
// API DE PRESUPUESTOS
|
// 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 = {
|
export const presupuestosApi = {
|
||||||
list: async (filters?: PresupuestoFilters): Promise<PaginatedResponse<Presupuesto>> => {
|
list: async (filters?: PresupuestoFilters): Promise<PaginatedResponse<Presupuesto>> => {
|
||||||
const response = await api.get<PaginatedResponse<Presupuesto>>('/presupuestos', {
|
const response = await api.get<PaginatedResponse<Presupuesto>>('/presupuestos', {
|
||||||
@ -152,6 +196,11 @@ export const presupuestosApi = {
|
|||||||
return response.data;
|
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> => {
|
create: async (data: CreatePresupuestoDto): Promise<Presupuesto> => {
|
||||||
const response = await api.post<Presupuesto>('/presupuestos', data);
|
const response = await api.post<Presupuesto>('/presupuestos', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -171,8 +220,59 @@ export const presupuestosApi = {
|
|||||||
return response.data;
|
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> => {
|
duplicate: async (id: string): Promise<Presupuesto> => {
|
||||||
const response = await api.post<Presupuesto>(`/presupuestos/${id}/duplicate`);
|
const response = await api.post<Presupuesto>(`/presupuestos/${id}/duplicate`);
|
||||||
return response.data;
|
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}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user