diff --git a/web/src/App.tsx b/web/src/App.tsx index cb72843..e8f26b5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/web/src/hooks/usePresupuestos.ts b/web/src/hooks/usePresupuestos.ts index 9c6e747..67112ca 100644 --- a/web/src/hooks/usePresupuestos.ts +++ b/web/src/hooks/usePresupuestos.ts @@ -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) { diff --git a/web/src/pages/admin/presupuestos/PresupuestoDetailPage.tsx b/web/src/pages/admin/presupuestos/PresupuestoDetailPage.tsx new file mode 100644 index 0000000..4ad25dd --- /dev/null +++ b/web/src/pages/admin/presupuestos/PresupuestoDetailPage.tsx @@ -0,0 +1,1174 @@ +import { useState, useMemo } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { + ArrowLeft, + Plus, + Pencil, + Trash2, + CheckCircle, + XCircle, + Copy, + FileDown, + FileSpreadsheet, + History, + ChevronRight, + ChevronDown, + Calendar, + Building2, + Home, + DollarSign, + Package, + AlertTriangle, + X, + Search, +} from 'lucide-react'; +import { + usePresupuestoWithPartidas, + usePresupuestoVersions, + useApprovePresupuesto, + useRejectPresupuesto, + useCreatePresupuestoVersion, + useAddPresupuestoPartida, + useUpdatePresupuestoPartida, + useDeletePresupuestoPartida, + useExportPresupuestoPdf, + useExportPresupuestoExcel, + useConceptosTree, +} from '../../../hooks/usePresupuestos'; +import { + PresupuestoEstado, + PresupuestoPartida, + PresupuestoVersion, + Concepto, + CreatePresupuestoPartidaDto, + UpdatePresupuestoPartidaDto, +} from '../../../services/presupuestos'; +import clsx from 'clsx'; + +const estadoColors: Record = { + 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 = { + borrador: 'Borrador', + revision: 'En Revision', + aprobado: 'Aprobado', + cerrado: 'Cerrado', +}; + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +interface PartidaWithChildren extends PresupuestoPartida { + children?: PartidaWithChildren[]; + level: number; +} + +function buildPartidaTree(partidas: PresupuestoPartida[]): PartidaWithChildren[] { + const partidaMap = new Map(); + const roots: PartidaWithChildren[] = []; + + partidas.forEach((partida) => { + partidaMap.set(partida.id, { ...partida, children: [], level: 0 }); + }); + + partidas.forEach((partida) => { + const partidaWithChildren = partidaMap.get(partida.id)!; + const parentConcepto = partida.concepto?.parentId; + + if (parentConcepto) { + const parentPartida = Array.from(partidaMap.values()).find( + (p) => p.concepto?.id === parentConcepto + ); + if (parentPartida) { + partidaWithChildren.level = parentPartida.level + 1; + parentPartida.children = parentPartida.children || []; + parentPartida.children.push(partidaWithChildren); + } else { + roots.push(partidaWithChildren); + } + } else { + roots.push(partidaWithChildren); + } + }); + + return roots; +} + +function flattenPartidaTree(tree: PartidaWithChildren[]): PartidaWithChildren[] { + const result: PartidaWithChildren[] = []; + + function traverse(nodes: PartidaWithChildren[]) { + nodes.forEach((node) => { + result.push(node); + if (node.children && node.children.length > 0) { + traverse(node.children); + } + }); + } + + traverse(tree); + return result; +} + +export function PresupuestoDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [showVersionSidebar, setShowVersionSidebar] = useState(false); + const [showPartidaModal, setShowPartidaModal] = useState(false); + const [editingPartida, setEditingPartida] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [showApproveModal, setShowApproveModal] = useState(false); + const [showRejectModal, setShowRejectModal] = useState(false); + const [showVersionModal, setShowVersionModal] = useState(false); + const [expandedConceptos, setExpandedConceptos] = useState>(new Set()); + + const { data: presupuesto, isLoading, error } = usePresupuestoWithPartidas(id!); + const { data: versions } = usePresupuestoVersions(id!); + + const approveMutation = useApprovePresupuesto(); + const rejectMutation = useRejectPresupuesto(); + const createVersionMutation = useCreatePresupuestoVersion(); + const addPartidaMutation = useAddPresupuestoPartida(); + const updatePartidaMutation = useUpdatePresupuestoPartida(); + const deletePartidaMutation = useDeletePresupuestoPartida(); + const exportPdfMutation = useExportPresupuestoPdf(); + const exportExcelMutation = useExportPresupuestoExcel(); + + const partidas = useMemo(() => { + if (!presupuesto?.partidas) return []; + const tree = buildPartidaTree(presupuesto.partidas); + return flattenPartidaTree(tree); + }, [presupuesto?.partidas]); + + const grandTotal = useMemo(() => { + return partidas.reduce((sum, p) => sum + p.importe, 0); + }, [partidas]); + + const handleApprove = async () => { + await approveMutation.mutateAsync(id!); + setShowApproveModal(false); + }; + + const handleReject = async (motivo: string) => { + await rejectMutation.mutateAsync({ id: id!, data: { motivo } }); + setShowRejectModal(false); + }; + + const handleCreateVersion = async (notas?: string) => { + await createVersionMutation.mutateAsync({ id: id!, notas }); + setShowVersionModal(false); + }; + + const handleAddPartida = async (data: CreatePresupuestoPartidaDto) => { + await addPartidaMutation.mutateAsync({ presupuestoId: id!, data }); + setShowPartidaModal(false); + }; + + const handleUpdatePartida = async (partidaId: string, data: UpdatePresupuestoPartidaDto) => { + await updatePartidaMutation.mutateAsync({ presupuestoId: id!, partidaId, data }); + setEditingPartida(null); + }; + + const handleDeletePartida = async (partidaId: string) => { + await deletePartidaMutation.mutateAsync({ presupuestoId: id!, partidaId }); + setDeleteConfirm(null); + }; + + const handleExportPdf = () => { + exportPdfMutation.mutate(id!); + }; + + const handleExportExcel = () => { + exportExcelMutation.mutate(id!); + }; + + const toggleConcepto = (conceptoId: string) => { + setExpandedConceptos((prev) => { + const newSet = new Set(prev); + if (newSet.has(conceptoId)) { + newSet.delete(conceptoId); + } else { + newSet.add(conceptoId); + } + return newSet; + }); + }; + + const canEdit = presupuesto?.estado === 'borrador' || presupuesto?.estado === 'revision'; + const canApprove = presupuesto?.estado === 'revision'; + const canCreateVersion = presupuesto?.estado === 'aprobado' || presupuesto?.estado === 'cerrado'; + + if (isLoading) { + return ( +
+
Cargando presupuesto...
+
+ ); + } + + if (error || !presupuesto) { + return ( +
+ +

Error al cargar el presupuesto

+ +
+ ); + } + + return ( +
+
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+
+
+

{presupuesto.nombre}

+ + {estadoLabels[presupuesto.estado]} + + + v{presupuesto.version} + +
+

Codigo: {presupuesto.codigo}

+
+
+ + {canEdit && ( + + )} + {canCreateVersion && ( + + )} + {canApprove && ( + + )} +
+ +
+ + +
+
+
+ + {/* General Info Card */} +
+

Informacion General

+
+
+ +

+ {(presupuesto as any).fraccionamiento?.nombre || 'Sin asignar'} +

+
+
+ +

+ {(presupuesto as any).prototipo?.nombre || 'Sin asignar'} +

+
+
+ +

{formatDate(presupuesto.fechaCreacion)}

+
+
+ +

{formatDate(presupuesto.updatedAt)}

+
+
+
+
+ +

+ {formatCurrency(presupuesto.montoTotal)} +

+
+
+
+ + {/* Partidas Table */} +
+
+
+ +

Partidas del Presupuesto

+ ({partidas.length} partidas) +
+ {canEdit && ( + + )} +
+ + {partidas.length === 0 ? ( +
+ +

No hay partidas en este presupuesto

+ {canEdit && ( + + )} +
+ ) : ( +
+ + + + + + + + + + {canEdit && ( + + )} + + + + {partidas.map((partida) => { + const hasChildren = + partida.children && partida.children.length > 0; + const isExpanded = expandedConceptos.has(partida.concepto?.id || ''); + const isCapitulo = partida.concepto?.tipo === 'capitulo'; + + return ( + + + + + + + + {canEdit && ( + + )} + + ); + })} + + + + + + {canEdit && } + + +
+ Codigo + + Descripcion + + Unidad + + Cantidad + + Precio Unitario + + Importe + + Acciones +
+
+ {hasChildren && ( + + )} + + {partida.concepto?.codigo || '-'} + +
+
+ {partida.concepto?.descripcion || '-'} + + {partida.concepto?.unidad || '-'} + + {partida.cantidad.toLocaleString('es-MX', { + minimumFractionDigits: 2, + maximumFractionDigits: 4, + })} + + {formatCurrency(partida.precioUnitario)} + + {formatCurrency(partida.importe)} + +
+ + +
+
+ TOTAL GENERAL: + + {formatCurrency(grandTotal)} +
+
+ )} +
+ + {/* Approval Section */} + {canApprove && ( +
+

Aprobacion

+

+ Este presupuesto esta en revision y puede ser aprobado o rechazado. +

+
+ + +
+
+ )} +
+ + {/* Version History Sidebar */} +
+
+
+

Historial de Versiones

+ +
+
+
+ {versions && versions.length > 0 ? ( +
+ {versions.map((version) => ( + { + if (version.id !== presupuesto.id) { + navigate(`/admin/presupuestos/presupuestos/${version.id}`); + } + }} + /> + ))} +
+ ) : ( +
+ +

No hay versiones anteriores

+
+ )} +
+
+ + {/* Partida Modal */} + {showPartidaModal && ( + { + setShowPartidaModal(false); + setEditingPartida(null); + }} + onSubmit={ + editingPartida + ? (data) => handleUpdatePartida(editingPartida.id, data) + : handleAddPartida + } + isLoading={addPartidaMutation.isPending || updatePartidaMutation.isPending} + /> + )} + + {/* Delete Confirmation */} + {deleteConfirm && ( +
+
+

Confirmar eliminacion

+

+ ¿Esta seguro de eliminar esta partida del presupuesto? +

+
+ + +
+
+
+ )} + + {/* Approve Modal */} + {showApproveModal && ( +
+
+
+ +

Aprobar Presupuesto

+
+

+ ¿Esta seguro de aprobar este presupuesto? Una vez aprobado, no podra ser editado + directamente. Debera crear una nueva version para realizar cambios. +

+
+ + +
+
+
+ )} + + {/* Reject Modal */} + {showRejectModal && ( + setShowRejectModal(false)} + onSubmit={handleReject} + isLoading={rejectMutation.isPending} + /> + )} + + {/* Create Version Modal */} + {showVersionModal && ( + setShowVersionModal(false)} + onSubmit={handleCreateVersion} + isLoading={createVersionMutation.isPending} + /> + )} +
+ ); +} + +interface VersionCardProps { + version: PresupuestoVersion; + isCurrent: boolean; + onSelect: () => void; +} + +function VersionCard({ version, isCurrent, onSelect }: VersionCardProps) { + return ( +
+
+ Version {version.version} + {isCurrent && ( + + Actual + + )} +
+
+ {formatDate(version.fechaCreacion)} +
+
+ {formatCurrency(version.montoTotal)} +
+
+ + {estadoLabels[version.estado]} + +
+ {version.notas && ( +

{version.notas}

+ )} +
+ ); +} + +interface PartidaModalProps { + partida: PresupuestoPartida | null; + onClose: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onSubmit: (data: any) => Promise; + isLoading: boolean; +} + +function PartidaModal({ partida, onClose, onSubmit, isLoading }: PartidaModalProps) { + const [conceptoSearch, setConceptoSearch] = useState(''); + const [selectedConcepto, setSelectedConcepto] = useState( + partida?.concepto || null + ); + const [cantidad, setCantidad] = useState(partida?.cantidad?.toString() || ''); + const [precioUnitario, setPrecioUnitario] = useState( + partida?.precioUnitario?.toString() || '' + ); + const [showConceptoDropdown, setShowConceptoDropdown] = useState(false); + + const { data: conceptosTree } = useConceptosTree(); + + const filteredConceptos = useMemo(() => { + if (!conceptosTree) return []; + + const flattenTree = (conceptos: Concepto[]): Concepto[] => { + return conceptos.reduce((acc, concepto) => { + acc.push(concepto); + if (concepto.children && concepto.children.length > 0) { + acc.push(...flattenTree(concepto.children)); + } + return acc; + }, []); + }; + + const allConceptos = flattenTree(conceptosTree); + + if (!conceptoSearch) return allConceptos.slice(0, 20); + + const searchLower = conceptoSearch.toLowerCase(); + return allConceptos + .filter( + (c) => + c.codigo.toLowerCase().includes(searchLower) || + c.descripcion.toLowerCase().includes(searchLower) + ) + .slice(0, 20); + }, [conceptosTree, conceptoSearch]); + + const importe = useMemo(() => { + const cant = parseFloat(cantidad) || 0; + const precio = parseFloat(precioUnitario) || 0; + return cant * precio; + }, [cantidad, precioUnitario]); + + const handleSelectConcepto = (concepto: Concepto) => { + setSelectedConcepto(concepto); + setConceptoSearch(''); + setShowConceptoDropdown(false); + if (concepto.precioUnitario && !precioUnitario) { + setPrecioUnitario(concepto.precioUnitario.toString()); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (partida) { + await onSubmit({ + cantidad: parseFloat(cantidad), + precioUnitario: parseFloat(precioUnitario), + }); + } else { + if (!selectedConcepto) return; + await onSubmit({ + conceptoId: selectedConcepto.id, + cantidad: parseFloat(cantidad), + precioUnitario: parseFloat(precioUnitario), + }); + } + }; + + return ( +
+
+
+

+ {partida ? 'Editar Partida' : 'Agregar Partida'} +

+ +
+ +
+ {/* Concepto Selection */} +
+ + {partida ? ( +
+
+ {selectedConcepto?.codigo} +
+
+ {selectedConcepto?.descripcion} +
+
+ ) : ( +
+
+ + { + setConceptoSearch(e.target.value); + setShowConceptoDropdown(true); + }} + onFocus={() => setShowConceptoDropdown(true)} + /> +
+ + {selectedConcepto && ( +
+
+
+
+ {selectedConcepto.codigo} +
+
+ {selectedConcepto.descripcion} +
+ {selectedConcepto.unidad && ( +
+ Unidad: {selectedConcepto.unidad} +
+ )} +
+ +
+
+ )} + + {showConceptoDropdown && !selectedConcepto && ( +
+ {filteredConceptos.length === 0 ? ( +
+ No se encontraron conceptos +
+ ) : ( + filteredConceptos.map((concepto) => ( + + )) + )} +
+ )} +
+ )} +
+ + {/* Cantidad and Precio Unitario */} +
+
+ + setCantidad(e.target.value)} + /> + {selectedConcepto?.unidad && ( +

+ Unidad: {selectedConcepto.unidad} +

+ )} +
+
+ +
+ + $ + + setPrecioUnitario(e.target.value)} + /> +
+ {selectedConcepto?.precioUnitario && ( +

+ Precio catalogo: {formatCurrency(selectedConcepto.precioUnitario)} +

+ )} +
+
+ + {/* Importe (calculated) */} +
+
+ + {formatCurrency(importe)} +
+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} + +interface RejectModalProps { + onClose: () => void; + onSubmit: (motivo: string) => Promise; + isLoading: boolean; +} + +function RejectModal({ onClose, onSubmit, isLoading }: RejectModalProps) { + const [motivo, setMotivo] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit(motivo); + }; + + return ( +
+
+
+ +

Rechazar Presupuesto

+
+ +
+
+ +