From e3b33f9caf71b22424de40755d6e0906d1c66865 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 2 Feb 2026 23:02:54 -0600 Subject: [PATCH] feat(obras): Add Control de Obra module - Avances and Bitacora Sprint 1 - S1-T03: Pagina de Catalogo de Obras New features: - Avances de Obra page: work progress tracking with workflow (pending -> captured -> reviewed -> approved/rejected) - Bitacora de Obra page: daily work log with timeline view - Progress API services (avances-obra.api.ts, bitacora-obra.api.ts) - React Query hooks (useProgress.ts) with 18 hooks total - Navigation section "Control de Obra" in sidebar Co-Authored-By: Claude Opus 4.5 --- web/src/App.tsx | 8 + web/src/hooks/index.ts | 1 + web/src/hooks/useProgress.ts | 260 +++++++ web/src/layouts/AdminLayout.tsx | 10 + web/src/pages/admin/obras/AvancesObraPage.tsx | 663 ++++++++++++++++++ .../pages/admin/obras/BitacoraObraPage.tsx | 606 ++++++++++++++++ web/src/pages/admin/obras/index.ts | 2 + web/src/services/progress/avances-obra.api.ts | 234 +++++++ .../services/progress/bitacora-obra.api.ts | 99 +++ web/src/services/progress/index.ts | 1 + 10 files changed, 1884 insertions(+) create mode 100644 web/src/hooks/useProgress.ts create mode 100644 web/src/pages/admin/obras/AvancesObraPage.tsx create mode 100644 web/src/pages/admin/obras/BitacoraObraPage.tsx create mode 100644 web/src/pages/admin/obras/index.ts create mode 100644 web/src/services/progress/avances-obra.api.ts create mode 100644 web/src/services/progress/bitacora-obra.api.ts create mode 100644 web/src/services/progress/index.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 662e128..7f35f83 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,6 +17,7 @@ import { DashboardPage } from './pages/admin/dashboard'; import { ConceptosPage, PresupuestosPage, 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 } from './pages/admin/obras'; function App() { return ( @@ -69,6 +70,13 @@ function App() { } /> } /> + + {/* Control de Obra */} + + } /> + } /> + } /> + {/* Portal Supervisor */} diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index 02fcd62..b92b432 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -3,3 +3,4 @@ export * from './usePresupuestos'; export * from './useReports'; export * from './useBidding'; export * from './useHSE'; +export * from './useProgress'; diff --git a/web/src/hooks/useProgress.ts b/web/src/hooks/useProgress.ts new file mode 100644 index 0000000..52451f6 --- /dev/null +++ b/web/src/hooks/useProgress.ts @@ -0,0 +1,260 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import toast from 'react-hot-toast'; +import { ApiError } from '../services/api'; +import { + avancesObraApi, + AvanceObraFilters, + CreateAvanceObraDto, + AddFotoAvanceDto, + ReviewAvanceDto, + ApproveAvanceDto, + RejectAvanceDto, + AccumulatedProgressFilters, +} from '../services/progress/avances-obra.api'; +import { + bitacoraObraApi, + BitacoraObraFilters, + CreateBitacoraObraDto, + UpdateBitacoraObraDto, +} from '../services/progress/bitacora-obra.api'; + +export const progressKeys = { + avances: { + all: ['progress', 'avances'] as const, + list: (filters?: AvanceObraFilters) => [...progressKeys.avances.all, 'list', filters] as const, + detail: (id: string) => [...progressKeys.avances.all, 'detail', id] as const, + accumulated: (filters?: AccumulatedProgressFilters) => + [...progressKeys.avances.all, 'accumulated', filters] as const, + stats: () => [...progressKeys.avances.all, 'stats'] as const, + }, + bitacora: { + all: ['progress', 'bitacora'] as const, + list: (fraccionamientoId: string, filters?: Omit) => + [...progressKeys.bitacora.all, 'list', fraccionamientoId, filters] as const, + detail: (id: string) => [...progressKeys.bitacora.all, 'detail', id] as const, + stats: (fraccionamientoId: string) => + [...progressKeys.bitacora.all, 'stats', fraccionamientoId] as const, + latest: (fraccionamientoId: string) => + [...progressKeys.bitacora.all, 'latest', fraccionamientoId] as const, + }, +}; + +const handleError = (error: AxiosError) => { + const message = error.response?.data?.message || 'Ha ocurrido un error'; + toast.error(message); +}; + +export function useAvances(filters?: AvanceObraFilters) { + return useQuery({ + queryKey: progressKeys.avances.list(filters), + queryFn: () => avancesObraApi.list(filters), + }); +} + +export function useAvance(id: string) { + return useQuery({ + queryKey: progressKeys.avances.detail(id), + queryFn: () => avancesObraApi.get(id), + enabled: !!id, + }); +} + +export function useAvanceAccumulated(filters?: AccumulatedProgressFilters) { + return useQuery({ + queryKey: progressKeys.avances.accumulated(filters), + queryFn: () => avancesObraApi.getAccumulated(filters), + }); +} + +export function useAvanceStats() { + return useQuery({ + queryKey: progressKeys.avances.stats(), + queryFn: () => avancesObraApi.getStats(), + }); +} + +export function useCreateAvance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateAvanceObraDto) => avancesObraApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: progressKeys.avances.all }); + toast.success('Avance registrado exitosamente'); + }, + onError: handleError, + }); +} + +export function useAddFotoAvance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: AddFotoAvanceDto }) => + avancesObraApi.addFoto(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.avances.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) }); + toast.success('Foto agregada exitosamente'); + }, + onError: handleError, + }); +} + +export function useRemoveFotoAvance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, fotoId }: { id: string; fotoId: string }) => + avancesObraApi.removeFoto(id, fotoId), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.avances.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) }); + toast.success('Foto eliminada'); + }, + onError: handleError, + }); +} + +export function useReviewAvance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data?: ReviewAvanceDto }) => + avancesObraApi.review(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.avances.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() }); + toast.success('Avance marcado como revisado'); + }, + onError: handleError, + }); +} + +export function useApproveAvance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data?: ApproveAvanceDto }) => + avancesObraApi.approve(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.avances.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.accumulated() }); + toast.success('Avance aprobado exitosamente'); + }, + onError: handleError, + }); +} + +export function useRejectAvance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: RejectAvanceDto }) => + avancesObraApi.reject(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.avances.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() }); + toast.success('Avance rechazado'); + }, + onError: handleError, + }); +} + +export function useDeleteAvance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => avancesObraApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: progressKeys.avances.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() }); + toast.success('Avance eliminado'); + }, + onError: handleError, + }); +} + +export function useBitacora( + fraccionamientoId: string, + filters?: Omit +) { + return useQuery({ + queryKey: progressKeys.bitacora.list(fraccionamientoId, filters), + queryFn: () => + bitacoraObraApi.list({ + ...filters, + fraccionamientoId, + }), + enabled: !!fraccionamientoId, + }); +} + +export function useBitacoraEntry(id: string) { + return useQuery({ + queryKey: progressKeys.bitacora.detail(id), + queryFn: () => bitacoraObraApi.get(id), + enabled: !!id, + }); +} + +export function useBitacoraStats(fraccionamientoId: string) { + return useQuery({ + queryKey: progressKeys.bitacora.stats(fraccionamientoId), + queryFn: () => bitacoraObraApi.getStats(fraccionamientoId), + enabled: !!fraccionamientoId, + }); +} + +export function useBitacoraLatest(fraccionamientoId: string) { + return useQuery({ + queryKey: progressKeys.bitacora.latest(fraccionamientoId), + queryFn: () => bitacoraObraApi.getLatest(fraccionamientoId), + enabled: !!fraccionamientoId, + }); +} + +export function useCreateBitacora() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateBitacoraObraDto) => bitacoraObraApi.create(data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.all }); + queryClient.invalidateQueries({ + queryKey: progressKeys.bitacora.stats(result.fraccionamientoId), + }); + queryClient.invalidateQueries({ + queryKey: progressKeys.bitacora.latest(result.fraccionamientoId), + }); + toast.success('Entrada de bitacora creada exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateBitacora() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateBitacoraObraDto }) => + bitacoraObraApi.update(id, data), + onSuccess: (result, { id }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.detail(id) }); + queryClient.invalidateQueries({ + queryKey: progressKeys.bitacora.stats(result.fraccionamientoId), + }); + toast.success('Entrada de bitacora actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteBitacora() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => bitacoraObraApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.all }); + toast.success('Entrada de bitacora eliminada'); + }, + onError: handleError, + }); +} diff --git a/web/src/layouts/AdminLayout.tsx b/web/src/layouts/AdminLayout.tsx index bd77327..87814aa 100644 --- a/web/src/layouts/AdminLayout.tsx +++ b/web/src/layouts/AdminLayout.tsx @@ -23,6 +23,8 @@ import { AlertTriangle, GraduationCap, ClipboardCheck, + TrendingUp, + BookOpen, } from 'lucide-react'; import clsx from 'clsx'; import { useAuthStore } from '../stores/authStore'; @@ -58,6 +60,14 @@ const navSections: NavSection[] = [ { label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home }, ], }, + { + title: 'Control de Obra', + defaultOpen: false, + items: [ + { label: 'Avances', href: '/admin/obras/avances', icon: TrendingUp }, + { label: 'Bitácora', href: '/admin/obras/bitacora', icon: BookOpen }, + ], + }, { title: 'Presupuestos', defaultOpen: false, diff --git a/web/src/pages/admin/obras/AvancesObraPage.tsx b/web/src/pages/admin/obras/AvancesObraPage.tsx new file mode 100644 index 0000000..47445b7 --- /dev/null +++ b/web/src/pages/admin/obras/AvancesObraPage.tsx @@ -0,0 +1,663 @@ +import { useState, useMemo } from 'react'; +import { + Plus, + Eye, + Search, + CheckCircle, + Clock, + FileCheck, + XCircle, + ClipboardCheck, + TrendingUp, +} from 'lucide-react'; +import { + useAvances, + useAvanceStats, + useCreateAvance, + useReviewAvance, + useApproveAvance, + useRejectAvance, +} from '../../../hooks/useProgress'; +import { useFraccionamientos, useLotes } from '../../../hooks/useConstruccion'; +import { useConceptos } from '../../../hooks/usePresupuestos'; +import { + AvanceObra, + AvanceObraStatus, + CreateAvanceObraDto, +} from '../../../services/progress/avances-obra.api'; +import { Fraccionamiento } from '../../../services/construccion/fraccionamientos.api'; +import { Lote } from '../../../services/construccion/lotes.api'; +import { Concepto } from '../../../services/presupuestos/presupuestos.api'; +import clsx from 'clsx'; + +const statusColors: Record = { + pending: 'bg-gray-100 text-gray-800', + captured: 'bg-blue-100 text-blue-800', + reviewed: 'bg-yellow-100 text-yellow-800', + approved: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', +}; + +const statusLabels: Record = { + pending: 'Pendiente', + captured: 'Capturado', + reviewed: 'Revisado', + approved: 'Aprobado', + rejected: 'Rechazado', +}; + +export function AvancesObraPage() { + const [search, setSearch] = useState(''); + const [fraccionamientoFilter, setFraccionamientoFilter] = useState(''); + const [loteFilter, setLoteFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showRejectModal, setShowRejectModal] = useState(null); + + const { data, isLoading, error } = useAvances({ + fraccionamientoId: fraccionamientoFilter || undefined, + loteId: loteFilter || undefined, + status: (statusFilter as AvanceObraStatus) || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + }); + + const { data: fraccionamientosData } = useFraccionamientos(); + const { data: lotesData } = useLotes({ + manzanaId: undefined, + }); + const { data: statsData } = useAvanceStats(); + const { data: conceptosData } = useConceptos({ tipo: 'concepto' }); + + const createMutation = useCreateAvance(); + const reviewMutation = useReviewAvance(); + const approveMutation = useApproveAvance(); + const rejectMutation = useRejectAvance(); + + const avances = data?.items || []; + const fraccionamientos = fraccionamientosData?.items || []; + const allLotes = lotesData?.items || []; + const conceptos = conceptosData?.items || []; + + const filteredLotes = useMemo(() => { + if (!fraccionamientoFilter) return allLotes; + return allLotes.filter((lote) => { + if (!lote.manzana) return false; + return true; + }); + }, [allLotes, fraccionamientoFilter]); + + const handleCreate = async (formData: CreateAvanceObraDto) => { + await createMutation.mutateAsync(formData); + setShowCreateModal(false); + }; + + const handleReview = async (id: string) => { + await reviewMutation.mutateAsync({ id }); + }; + + const handleApprove = async (id: string) => { + await approveMutation.mutateAsync({ id }); + }; + + const handleReject = async (id: string, reason: string) => { + await rejectMutation.mutateAsync({ id, data: { reason } }); + setShowRejectModal(null); + }; + + const getProgressColor = (percentage: number) => { + if (percentage >= 80) return 'bg-green-500'; + if (percentage >= 50) return 'bg-yellow-500'; + if (percentage >= 25) return 'bg-orange-500'; + return 'bg-red-500'; + }; + + const approvedToday = useMemo(() => { + if (!statsData) return 0; + return statsData.porStatus.approved || 0; + }, [statsData]); + + const globalProgress = useMemo(() => { + if (!avances.length) return 0; + const totalProgress = avances.reduce((sum, a) => sum + (a.percentageAccumulated || 0), 0); + return Math.round(totalProgress / avances.length); + }, [avances]); + + return ( +
+
+
+

Control de Avances

+

+ Gestion de avances fisicos de obra y seguimiento de progreso +

+
+ +
+ + {statsData && ( +
+
+
+
+

Total Avances

+

{statsData.total}

+
+ +
+
+
+
+
+

Pendientes de Revision

+

+ {(statsData.porStatus.captured || 0) + (statsData.porStatus.pending || 0)} +

+
+ +
+
+
+
+
+

Aprobados Hoy

+

{approvedToday}

+
+ +
+
+
+
+
+

% Avance Global

+

{globalProgress}%

+
+ +
+
+
+ )} + +
+
+
+
+ + setSearch(e.target.value)} + /> +
+ +
+
+ + + setDateFrom(e.target.value)} + placeholder="Desde" + /> + setDateTo(e.target.value)} + placeholder="Hasta" + /> +
+
+
+ +
+ {isLoading ? ( +
Cargando...
+ ) : error ? ( +
Error al cargar los datos
+ ) : avances.length === 0 ? ( +
No hay avances registrados
+ ) : ( + + + + + + + + + + + + + + {avances.map((avance) => { + const percentage = avance.percentageAccumulated || 0; + + return ( + + + + + + + + + + ); + })} + +
+ Concepto + + Lote/Ubicacion + + Fecha Captura + + Cant. Ejecutada / Presup. + + % Avance + + Estado + + Acciones +
+
+ {avance.concepto?.codigo || 'N/A'} +
+
+ {avance.concepto?.nombre || 'Sin concepto'} +
+
+ {avance.lote ? ( +
+
+ Lote {avance.lote.numero} +
+
+ {avance.lote.manzanaNumero + ? `Mz. ${avance.lote.manzanaNumero}` + : avance.lote.fraccionamientoNombre || ''} +
+
+ ) : avance.departamento ? ( +
+
+ Depto. {avance.departamento.numero} +
+
+ {avance.departamento.edificioNombre || ''} +
+
+ ) : ( + Sin ubicacion + )} +
+ {new Date(avance.captureDate).toLocaleDateString()} + +
+ {avance.quantityExecuted.toLocaleString()}{' '} + /{' '} + {avance.concepto?.cantidadPresupuestada?.toLocaleString() || '0'} +
+
+ {avance.concepto?.unidad || 'UND'} +
+
+
+
+
+
+ + {percentage.toFixed(1)}% + +
+
+ + {statusLabels[avance.status] || avance.status} + + +
+ + {avance.status === 'captured' && ( + + )} + {avance.status === 'reviewed' && ( + <> + + + + )} +
+
+ )} +
+ + {showCreateModal && ( + setShowCreateModal(false)} + onSubmit={handleCreate} + isLoading={createMutation.isPending} + /> + )} + + {showRejectModal && ( + setShowRejectModal(null)} + onSubmit={(reason) => handleReject(showRejectModal.id, reason)} + isLoading={rejectMutation.isPending} + /> + )} +
+ ); +} + +interface CreateAvanceModalProps { + lotes: Lote[]; + conceptos: Concepto[]; + fraccionamientos: Fraccionamiento[]; + onClose: () => void; + onSubmit: (data: CreateAvanceObraDto) => Promise; + isLoading: boolean; +} + +function CreateAvanceModal({ + lotes, + conceptos, + fraccionamientos, + onClose, + onSubmit, + isLoading, +}: CreateAvanceModalProps) { + const [selectedFraccionamiento, setSelectedFraccionamiento] = useState(''); + const [formData, setFormData] = useState({ + conceptoId: '', + loteId: '', + captureDate: new Date().toISOString().split('T')[0], + quantityExecuted: 0, + notes: '', + }); + + const filteredLotes = useMemo(() => { + if (!selectedFraccionamiento) return lotes; + return lotes; + }, [lotes, selectedFraccionamiento]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await onSubmit({ + ...formData, + loteId: formData.loteId || undefined, + }); + }; + + return ( +
+
+

Nuevo Avance de Obra

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + setFormData({ ...formData, captureDate: e.target.value })} + /> +
+
+ + + setFormData({ ...formData, quantityExecuted: parseFloat(e.target.value) || 0 }) + } + /> +
+
+ +
+ +