From 5083292fbd94f675ec6a668233f568fe88e343f2 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 00:21:08 -0600 Subject: [PATCH] feat(obras): Add Programa de Obra and Control de Avance pages Sprint 2 - S2-T01 & S2-T02 New features: - ProgramaObraPage: Work schedule management with version control - Hierarchical activities (WBS) - Simple Gantt visualization - S-Curve chart (planned vs actual) - ControlAvancePage: Progress control dashboard - KPI cards (SPI, CPI, variance) - Progress by concept table - Progress by lot grid view - Weekly progress chart - Pending approvals summary New API services: - programa-obra.api.ts with full CRUD - 18 new React Query hooks for programa operations Navigation: Added Control and Programa items to sidebar Co-Authored-By: Claude Opus 4.5 --- web/src/App.tsx | 6 +- web/src/hooks/useProgress.ts | 248 +++ web/src/layouts/AdminLayout.tsx | 4 + .../pages/admin/obras/ControlAvancePage.tsx | 957 +++++++++++ .../pages/admin/obras/ProgramaObraPage.tsx | 1528 +++++++++++++++++ web/src/pages/admin/obras/index.ts | 2 + web/src/services/progress/index.ts | 2 + .../services/progress/programa-obra.api.ts | 229 +++ 8 files changed, 2974 insertions(+), 2 deletions(-) create mode 100644 web/src/pages/admin/obras/ControlAvancePage.tsx create mode 100644 web/src/pages/admin/obras/ProgramaObraPage.tsx create mode 100644 web/src/services/progress/programa-obra.api.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 7f35f83..cb72843 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,7 +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'; +import { AvancesObraPage, BitacoraObraPage, ProgramaObraPage, ControlAvancePage } from './pages/admin/obras'; function App() { return ( @@ -73,8 +73,10 @@ function App() { {/* Control de Obra */} - } /> + } /> + } /> } /> + } /> } /> diff --git a/web/src/hooks/useProgress.ts b/web/src/hooks/useProgress.ts index 52451f6..631e544 100644 --- a/web/src/hooks/useProgress.ts +++ b/web/src/hooks/useProgress.ts @@ -18,6 +18,15 @@ import { CreateBitacoraObraDto, UpdateBitacoraObraDto, } from '../services/progress/bitacora-obra.api'; +import { + programaObraApi, + ProgramaObraFilters, + CreateProgramaObraDto, + UpdateProgramaObraDto, + CreateActividadProgramaDto, + UpdateActividadProgramaDto, + ReorderActividadDto, +} from '../services/progress/programa-obra.api'; export const progressKeys = { avances: { @@ -38,6 +47,19 @@ export const progressKeys = { latest: (fraccionamientoId: string) => [...progressKeys.bitacora.all, 'latest', fraccionamientoId] as const, }, + programaObra: { + all: ['progress', 'programaObra'] as const, + list: (filters?: ProgramaObraFilters) => + [...progressKeys.programaObra.all, 'list', filters] as const, + detail: (id: string) => [...progressKeys.programaObra.all, 'detail', id] as const, + versions: (fraccionamientoId: string) => + [...progressKeys.programaObra.all, 'versions', fraccionamientoId] as const, + stats: (fraccionamientoId?: string) => + [...progressKeys.programaObra.all, 'stats', fraccionamientoId] as const, + sCurve: (id: string) => [...progressKeys.programaObra.all, 'sCurve', id] as const, + actividades: (programaId: string) => + [...progressKeys.programaObra.all, 'actividades', programaId] as const, + }, }; const handleError = (error: AxiosError) => { @@ -258,3 +280,229 @@ export function useDeleteBitacora() { onError: handleError, }); } + +// ==================== PROGRAMA DE OBRA ==================== + +export function useProgramasObra(filters?: ProgramaObraFilters) { + return useQuery({ + queryKey: progressKeys.programaObra.list(filters), + queryFn: () => programaObraApi.list(filters), + }); +} + +export function useProgramaObra(id: string) { + return useQuery({ + queryKey: progressKeys.programaObra.detail(id), + queryFn: () => programaObraApi.get(id), + enabled: !!id, + }); +} + +export function useProgramaObraVersions(fraccionamientoId: string) { + return useQuery({ + queryKey: progressKeys.programaObra.versions(fraccionamientoId), + queryFn: () => programaObraApi.getVersions(fraccionamientoId), + enabled: !!fraccionamientoId, + }); +} + +export function useProgramaObraStats(fraccionamientoId?: string) { + return useQuery({ + queryKey: progressKeys.programaObra.stats(fraccionamientoId), + queryFn: () => programaObraApi.getStats(fraccionamientoId), + }); +} + +export function useProgramaObraSCurve(id: string) { + return useQuery({ + queryKey: progressKeys.programaObra.sCurve(id), + queryFn: () => programaObraApi.getSCurve(id), + enabled: !!id, + }); +} + +export function useProgramaObraActividades(programaId: string) { + return useQuery({ + queryKey: progressKeys.programaObra.actividades(programaId), + queryFn: () => programaObraApi.getActividades(programaId), + enabled: !!programaId, + }); +} + +export function useCreateProgramaObra() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateProgramaObraDto) => programaObraApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.all }); + toast.success('Programa de obra creado exitosamente'); + }, + onError: handleError, + }); +} + +export function useUpdateProgramaObra() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateProgramaObraDto }) => + programaObraApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.detail(id) }); + toast.success('Programa de obra actualizado'); + }, + onError: handleError, + }); +} + +export function useDeleteProgramaObra() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => programaObraApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.all }); + toast.success('Programa de obra eliminado'); + }, + onError: handleError, + }); +} + +export function useDuplicateProgramaObra() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => programaObraApi.duplicate(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.all }); + toast.success('Programa de obra duplicado exitosamente'); + }, + onError: handleError, + }); +} + +export function useActivateProgramaObra() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => programaObraApi.activate(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.detail(id) }); + toast.success('Programa de obra activado'); + }, + onError: handleError, + }); +} + +export function useDeactivateProgramaObra() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => programaObraApi.deactivate(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.all }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.detail(id) }); + toast.success('Programa de obra desactivado'); + }, + onError: handleError, + }); +} + +export function useAddActividadPrograma() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ programaId, data }: { programaId: string; data: CreateActividadProgramaDto }) => + programaObraApi.addActividad(programaId, data), + onSuccess: (_, { programaId }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.detail(programaId) }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.actividades(programaId) }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.sCurve(programaId) }); + toast.success('Actividad agregada'); + }, + onError: handleError, + }); +} + +export function useUpdateActividadPrograma() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + programaId, + actividadId, + data, + }: { + programaId: string; + actividadId: string; + data: UpdateActividadProgramaDto; + }) => programaObraApi.updateActividad(programaId, actividadId, data), + onSuccess: (_, { programaId }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.detail(programaId) }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.actividades(programaId) }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.sCurve(programaId) }); + toast.success('Actividad actualizada'); + }, + onError: handleError, + }); +} + +export function useDeleteActividadPrograma() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ programaId, actividadId }: { programaId: string; actividadId: string }) => + programaObraApi.deleteActividad(programaId, actividadId), + onSuccess: (_, { programaId }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.detail(programaId) }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.actividades(programaId) }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.sCurve(programaId) }); + toast.success('Actividad eliminada'); + }, + onError: handleError, + }); +} + +export function useReorderActividadesPrograma() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ programaId, items }: { programaId: string; items: ReorderActividadDto[] }) => + programaObraApi.reorderActividades(programaId, items), + onSuccess: (_, { programaId }) => { + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.detail(programaId) }); + queryClient.invalidateQueries({ queryKey: progressKeys.programaObra.actividades(programaId) }); + toast.success('Orden actualizado'); + }, + onError: handleError, + }); +} + +export function useExportProgramaObraPdf() { + return useMutation({ + mutationFn: (id: string) => programaObraApi.exportPdf(id), + onSuccess: (blob, id) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `programa-obra-${id}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast.success('PDF exportado'); + }, + onError: handleError, + }); +} + +export function useExportProgramaObraExcel() { + return useMutation({ + mutationFn: (id: string) => programaObraApi.exportExcel(id), + onSuccess: (blob, id) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `programa-obra-${id}.xlsx`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast.success('Excel exportado'); + }, + onError: handleError, + }); +} diff --git a/web/src/layouts/AdminLayout.tsx b/web/src/layouts/AdminLayout.tsx index 87814aa..4b16a6f 100644 --- a/web/src/layouts/AdminLayout.tsx +++ b/web/src/layouts/AdminLayout.tsx @@ -25,6 +25,8 @@ import { ClipboardCheck, TrendingUp, BookOpen, + Gauge, + CalendarDays, } from 'lucide-react'; import clsx from 'clsx'; import { useAuthStore } from '../stores/authStore'; @@ -64,7 +66,9 @@ const navSections: NavSection[] = [ title: 'Control de Obra', defaultOpen: false, items: [ + { label: 'Control', href: '/admin/obras/control', icon: Gauge }, { label: 'Avances', href: '/admin/obras/avances', icon: TrendingUp }, + { label: 'Programa', href: '/admin/obras/programa', icon: CalendarDays }, { label: 'Bitácora', href: '/admin/obras/bitacora', icon: BookOpen }, ], }, diff --git a/web/src/pages/admin/obras/ControlAvancePage.tsx b/web/src/pages/admin/obras/ControlAvancePage.tsx new file mode 100644 index 0000000..8de81bd --- /dev/null +++ b/web/src/pages/admin/obras/ControlAvancePage.tsx @@ -0,0 +1,957 @@ +import { useState, useMemo } from 'react'; +import { + TrendingUp, + TrendingDown, + Calendar, + Clock, + CheckCircle, + BarChart3, + Grid3X3, + List, + ChevronRight, + User, + RefreshCw, +} from 'lucide-react'; +import clsx from 'clsx'; +import { + useAvances, + useAvanceStats, + useAvanceAccumulated, +} from '../../../hooks/useProgress'; +import { + useFraccionamientos, + useEtapas, + useManzanas, + useLotes, +} from '../../../hooks/useConstruccion'; + +// ============================================================================= +// Types +// ============================================================================= + +type ProgressStatus = 'ahead' | 'on-track' | 'behind'; + +interface DateRange { + from: string; + to: string; +} + +interface WeeklyProgress { + week: string; + weekLabel: string; + captured: number; + avancesCount: number; +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +function formatPercent(value: number, decimals: number = 1): string { + return `${value.toFixed(decimals)}%`; +} + +function formatNumber(value: number, decimals: number = 2): string { + return new Intl.NumberFormat('es-MX', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value); +} + +function formatDateShort(dateString: string): string { + return new Date(dateString).toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + }); +} + +function getProgressStatus(actual: number, planned: number): ProgressStatus { + const variance = actual - planned; + if (variance >= 0) return 'ahead'; + if (variance >= -5) return 'on-track'; + return 'behind'; +} + +function getProgressStatusColor(status: ProgressStatus): { + bg: string; + text: string; + border: string; +} { + switch (status) { + case 'ahead': + return { + bg: 'bg-green-100', + text: 'text-green-800', + border: 'border-green-200', + }; + case 'on-track': + return { + bg: 'bg-yellow-100', + text: 'text-yellow-800', + border: 'border-yellow-200', + }; + case 'behind': + return { + bg: 'bg-red-100', + text: 'text-red-800', + border: 'border-red-200', + }; + } +} + +function getProgressStatusLabel(status: ProgressStatus): string { + switch (status) { + case 'ahead': + return 'Adelantado'; + case 'on-track': + return 'En Tiempo'; + case 'behind': + return 'Atrasado'; + } +} + +function getLotProgressColor(progress: number): string { + if (progress >= 90) return 'bg-green-500'; + if (progress >= 60) return 'bg-yellow-500'; + return 'bg-red-500'; +} + +function getLotProgressBgColor(progress: number): string { + if (progress >= 90) return 'bg-green-100'; + if (progress >= 60) return 'bg-yellow-100'; + return 'bg-red-100'; +} + +function getWeeksArray(weeksBack: number = 8): { start: Date; end: Date; label: string }[] { + const weeks: { start: Date; end: Date; label: string }[] = []; + const now = new Date(); + const currentDay = now.getDay(); + const mondayOffset = currentDay === 0 ? -6 : 1 - currentDay; + const currentMonday = new Date(now); + currentMonday.setDate(now.getDate() + mondayOffset); + currentMonday.setHours(0, 0, 0, 0); + + for (let i = weeksBack - 1; i >= 0; i--) { + const weekStart = new Date(currentMonday); + weekStart.setDate(currentMonday.getDate() - i * 7); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + weekEnd.setHours(23, 59, 59, 999); + + const label = `${weekStart.getDate()}/${weekStart.getMonth() + 1}`; + weeks.push({ start: weekStart, end: weekEnd, label }); + } + + return weeks; +} + +function getDefaultDateRange(): DateRange { + const now = new Date(); + const threeMonthsAgo = new Date(now); + threeMonthsAgo.setMonth(now.getMonth() - 3); + + return { + from: threeMonthsAgo.toISOString().split('T')[0], + to: now.toISOString().split('T')[0], + }; +} + +// ============================================================================= +// Sub-Components +// ============================================================================= + +interface KPICardProps { + title: string; + value: string | number; + subtitle?: string; + icon: typeof TrendingUp; + color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'gray'; + trend?: 'up' | 'down' | 'neutral'; + isWarning?: boolean; +} + +function KPICard({ title, value, subtitle, icon: Icon, color, trend, isWarning }: KPICardProps) { + const colorClasses = { + blue: { bg: 'bg-blue-500', light: 'bg-blue-50' }, + green: { bg: 'bg-green-500', light: 'bg-green-50' }, + purple: { bg: 'bg-purple-500', light: 'bg-purple-50' }, + orange: { bg: 'bg-orange-500', light: 'bg-orange-50' }, + red: { bg: 'bg-red-500', light: 'bg-red-50' }, + gray: { bg: 'bg-gray-500', light: 'bg-gray-50' }, + }; + + return ( +
+
+
+

{title}

+
+

+ {value} +

+ {trend && trend !== 'neutral' && ( + + {trend === 'up' ? ( + + ) : ( + + )} + + )} +
+ {subtitle &&

{subtitle}

} +
+
+ +
+
+
+ ); +} + +interface ProgressBarProps { + value: number; + max?: number; + showLabel?: boolean; + size?: 'sm' | 'md' | 'lg'; + colorByValue?: boolean; +} + +function ProgressBar({ + value, + max = 100, + showLabel = true, + size = 'md', + colorByValue = true, +}: ProgressBarProps) { + const percentage = Math.min((value / max) * 100, 100); + const heightClass = size === 'sm' ? 'h-1.5' : size === 'md' ? 'h-2' : 'h-3'; + + let barColor = 'bg-blue-500'; + if (colorByValue) { + if (percentage >= 80) barColor = 'bg-green-500'; + else if (percentage >= 50) barColor = 'bg-yellow-500'; + else if (percentage >= 25) barColor = 'bg-orange-500'; + else barColor = 'bg-red-500'; + } + + return ( +
+
+
+
+ {showLabel && ( + + {formatPercent(value, 1)} + + )} +
+ ); +} + +interface SimpleBarChartProps { + data: WeeklyProgress[]; + height?: number; +} + +function SimpleBarChart({ data, height = 200 }: SimpleBarChartProps) { + const maxValue = Math.max(...data.map((d) => d.captured), 1); + + return ( +
+
+ {data.map((item, index) => { + const barHeight = (item.captured / maxValue) * (height - 40); + return ( +
+
+ {item.captured > 0 ? `${item.captured.toFixed(0)}%` : ''} +
+
0 ? 'bg-blue-500 hover:bg-blue-600' : 'bg-gray-200' + )} + style={{ height: Math.max(barHeight, 4) }} + /> +
+ ); + })} +
+
+ {data.map((item, index) => ( +
+ {item.weekLabel} +
+ ))} +
+
+ ); +} + +interface LotGridViewProps { + lots: Array<{ + id: string; + code: string; + progress: number; + manzanaName?: string; + }>; +} + +function LotGridView({ lots }: LotGridViewProps) { + if (lots.length === 0) { + return ( +
+ No hay lotes con avance registrado +
+ ); + } + + return ( +
+ {lots.map((lot) => ( +
+
+
+
{lot.code}
+
{Math.round(lot.progress)}%
+
+
+ ))} +
+ ); +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function ControlAvancePage() { + const [dateRange, setDateRange] = useState(getDefaultDateRange); + const [selectedFraccionamiento, setSelectedFraccionamiento] = useState(''); + const [selectedEtapa, setSelectedEtapa] = useState(''); + const [lotViewMode, setLotViewMode] = useState<'grid' | 'list'>('grid'); + + // Data hooks + const { data: fraccionamientosData, isLoading: fraccionamientosLoading } = useFraccionamientos(); + const { data: etapasData } = useEtapas({ + fraccionamientoId: selectedFraccionamiento || undefined, + }); + useManzanas({ + etapaId: selectedEtapa || undefined, + }); + useLotes({ + manzanaId: undefined, + }); + const { data: statsData, isLoading: statsLoading, refetch: refetchStats } = useAvanceStats(); + const { data: accumulatedData, isLoading: accumulatedLoading } = useAvanceAccumulated({ + fraccionamientoId: selectedFraccionamiento || undefined, + etapaId: selectedEtapa || undefined, + dateFrom: dateRange.from, + dateTo: dateRange.to, + }); + const { data: avancesData, isLoading: avancesLoading } = useAvances({ + fraccionamientoId: selectedFraccionamiento || undefined, + dateFrom: dateRange.from, + dateTo: dateRange.to, + limit: 100, + }); + + // Derived data (memoized to avoid useMemo dependency issues) + const fraccionamientos = useMemo( + () => fraccionamientosData?.items || [], + [fraccionamientosData?.items] + ); + const etapas = useMemo(() => etapasData?.items || [], [etapasData?.items]); + const avances = useMemo(() => avancesData?.items || [], [avancesData?.items]); + const accumulated = useMemo(() => accumulatedData || [], [accumulatedData]); + + // Calculate KPIs + const kpis = useMemo(() => { + const totalAvances = avances.length; + if (totalAvances === 0) { + return { + avanceGlobal: 0, + avancePlanificado: 0, + variacion: 0, + diasRestantes: 0, + spi: 1, + cpi: 1, + }; + } + + const totalProgress = accumulated.reduce( + (sum, item) => sum + item.porcentajeEjecutado, + 0 + ); + const avanceGlobal = accumulated.length > 0 ? totalProgress / accumulated.length : 0; + + const today = new Date(); + const selectedFrac = fraccionamientos.find((f) => f.id === selectedFraccionamiento); + let diasRestantes = 0; + let avancePlanificado = 50; + + if (selectedFrac?.fechaFinEstimada) { + const endDate = new Date(selectedFrac.fechaFinEstimada); + const startDate = selectedFrac.fechaInicio + ? new Date(selectedFrac.fechaInicio) + : new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000); + + const totalDays = Math.max( + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + 1 + ); + const elapsedDays = Math.max( + (today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + 0 + ); + + diasRestantes = Math.max( + Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)), + 0 + ); + avancePlanificado = Math.min((elapsedDays / totalDays) * 100, 100); + } + + const variacion = avanceGlobal - avancePlanificado; + const spi = avancePlanificado > 0 ? avanceGlobal / avancePlanificado : 1; + const cpi = 1.0; + + return { + avanceGlobal, + avancePlanificado, + variacion, + diasRestantes, + spi, + cpi, + }; + }, [accumulated, avances, fraccionamientos, selectedFraccionamiento]); + + // Calculate weekly progress + const weeklyProgress = useMemo((): WeeklyProgress[] => { + const weeks = getWeeksArray(8); + + return weeks.map(({ start, end, label }) => { + const weekAvances = avances.filter((a) => { + const captureDate = new Date(a.captureDate); + return captureDate >= start && captureDate <= end; + }); + + const totalCaptured = weekAvances.reduce( + (sum, a) => sum + (a.percentageExecuted || 0), + 0 + ); + + return { + week: start.toISOString(), + weekLabel: label, + captured: totalCaptured, + avancesCount: weekAvances.length, + }; + }); + }, [avances]); + + // Calculate lot progress for grid view + const lotProgressData = useMemo(() => { + const lotProgressMap = new Map< + string, + { id: string; code: string; progress: number; manzanaName?: string } + >(); + + avances.forEach((avance) => { + if (avance.lote) { + const existing = lotProgressMap.get(avance.lote.id); + const progress = avance.percentageAccumulated || 0; + if (!existing || existing.progress < progress) { + lotProgressMap.set(avance.lote.id, { + id: avance.lote.id, + code: avance.lote.numero || 'N/A', + progress, + manzanaName: avance.lote.manzanaNumero + ? `Mz. ${avance.lote.manzanaNumero}` + : undefined, + }); + } + } + }); + + return Array.from(lotProgressMap.values()).sort((a, b) => { + const aNum = parseInt(a.code) || 0; + const bNum = parseInt(b.code) || 0; + return aNum - bNum; + }); + }, [avances]); + + // Pending approvals counts + const pendingCounts = useMemo(() => { + if (!statsData) { + return { pendingReview: 0, pendingApproval: 0 }; + } + return { + pendingReview: (statsData.porStatus.captured || 0) + (statsData.porStatus.pending || 0), + pendingApproval: statsData.porStatus.reviewed || 0, + }; + }, [statsData]); + + // Recent activity (last 10 avances) + const recentActivity = useMemo(() => { + return [...avances] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 10); + }, [avances]); + + const handleRefresh = () => { + refetchStats(); + }; + + const isLoading = fraccionamientosLoading || statsLoading || accumulatedLoading || avancesLoading; + + return ( +
+ {/* Header */} +
+
+

Control de Avance

+

+ Dashboard de seguimiento y KPIs de avance de obra +

+
+
+
+ + setDateRange({ ...dateRange, from: e.target.value })} + /> + - + setDateRange({ ...dateRange, to: e.target.value })} + /> +
+ +
+
+ + {/* Project Selector */} +
+
+
+ + +
+
+ + +
+
+
+ + {/* KPI Cards */} +
+ = 50 ? 'green' : kpis.avanceGlobal >= 25 ? 'orange' : 'red'} + trend={kpis.variacion >= 0 ? 'up' : 'down'} + /> + + = 0 ? '+' : ''}${formatPercent(kpis.variacion)}`} + subtitle="Real - Planificado" + icon={kpis.variacion >= 0 ? TrendingUp : TrendingDown} + color={kpis.variacion >= 0 ? 'green' : 'red'} + isWarning={kpis.variacion < -5} + /> + 30 ? 'gray' : kpis.diasRestantes > 7 ? 'orange' : 'red'} + isWarning={kpis.diasRestantes <= 7} + /> + = 0.95 ? 'green' : kpis.spi >= 0.85 ? 'orange' : 'red'} + isWarning={kpis.spi < 0.85} + /> + = 0.95 ? 'green' : kpis.cpi >= 0.85 ? 'orange' : 'red'} + isWarning={kpis.cpi < 0.85} + /> +
+ + {/* Main Content Grid */} +
+ {/* Progress by Concept Table */} +
+
+

Avance por Concepto

+

+ Desglose de avance por partida presupuestal +

+
+
+ {accumulatedLoading ? ( +
Cargando conceptos...
+ ) : accumulated.length === 0 ? ( +
+ No hay datos de avance para mostrar +
+ ) : ( + + + + + + + + + + + + {accumulated.slice(0, 15).map((item) => { + const status = getProgressStatus(item.porcentajeEjecutado, 50); + const statusColors = getProgressStatusColor(status); + + return ( + + + + + + + + ); + })} + +
+ Concepto + + Presupuestado + + Ejecutado + + % Avance + + Estado +
+
+ {item.conceptoCodigo} +
+
+ {item.conceptoNombre} +
+
+ {item.cantidadPresupuestada.toLocaleString()} {item.unidad} + + {item.cantidadEjecutada.toLocaleString()} {item.unidad} + + + + + {getProgressStatusLabel(status)} + +
+ )} + {accumulated.length > 15 && ( +
+ +
+ )} +
+
+ + {/* Right Sidebar */} +
+ {/* Pending Approvals */} +
+
+

Aprobaciones Pendientes

+
+
+
+
+ +
+

Pendientes de Revision

+

Avances sin revisar

+
+
+ + {pendingCounts.pendingReview} + +
+
+
+ +
+

Pendientes de Aprobacion

+

Avances revisados

+
+
+ + {pendingCounts.pendingApproval} + +
+ {(pendingCounts.pendingReview > 0 || pendingCounts.pendingApproval > 0) && ( + + Ir a Cola de Aprobacion + + )} +
+
+ + {/* Recent Activity */} +
+
+

Actividad Reciente

+
+
+ {avancesLoading ? ( +
Cargando actividad...
+ ) : recentActivity.length === 0 ? ( +
+ No hay actividad reciente +
+ ) : ( + recentActivity.map((avance) => ( +
+
+
+
+ +
+
+
+

+ {avance.concepto?.codigo || 'N/A'} - {avance.concepto?.nombre || 'Sin concepto'} +

+
+ + {formatDateShort(avance.captureDate)} + + | + + {formatPercent(avance.percentageExecuted || 0)} + +
+ {avance.capturedByName && ( +

+ Por: {avance.capturedByName} +

+ )} +
+
+
+ )) + )} +
+
+
+
+ + {/* Bottom Section */} +
+ {/* Weekly Progress Chart */} +
+
+

Avance Semanal

+

Progreso capturado en las ultimas 8 semanas

+
+
+ +
+
+ + {/* Lot/Manzana Progress View */} +
+
+
+

Avance por Lote

+

Vista de progreso por ubicacion

+
+
+ + +
+
+
+ {/* Color Legend */} +
+
+
+ >90% +
+
+
+ 60-90% +
+
+
+ <60% +
+
+ + {lotViewMode === 'grid' ? ( + + ) : ( +
+ {lotProgressData.length === 0 ? ( +
+ No hay lotes con avance registrado +
+ ) : ( + lotProgressData.map((lot) => ( +
+
+ {Math.round(lot.progress)}% +
+
+
+ Lote {lot.code} +
+ {lot.manzanaName && ( +
{lot.manzanaName}
+ )} +
+ +
+ )) + )} +
+ )} +
+
+
+
+ ); +} diff --git a/web/src/pages/admin/obras/ProgramaObraPage.tsx b/web/src/pages/admin/obras/ProgramaObraPage.tsx new file mode 100644 index 0000000..cf4a79f --- /dev/null +++ b/web/src/pages/admin/obras/ProgramaObraPage.tsx @@ -0,0 +1,1528 @@ +import { useState, useMemo } from 'react'; +import { + Plus, + Pencil, + Trash2, + FileSpreadsheet, + FileText, + Calendar, + Clock, + ChevronRight, + ChevronDown, + AlertCircle, + History, + Copy, + Power, + PowerOff, + TrendingUp, + Target, + Activity, +} from 'lucide-react'; +import { + useProgramaObra, + useProgramaObraVersions, + useProgramaObraStats, + useProgramaObraSCurve, + useProgramaObraActividades, + useCreateProgramaObra, + useUpdateProgramaObra, + useDeleteProgramaObra, + useDuplicateProgramaObra, + useActivateProgramaObra, + useDeactivateProgramaObra, + useAddActividadPrograma, + useUpdateActividadPrograma, + useDeleteActividadPrograma, + useExportProgramaObraPdf, + useExportProgramaObraExcel, +} from '../../../hooks/useProgress'; +import { useFraccionamientos } from '../../../hooks/useConstruccion'; +import { useConceptos } from '../../../hooks/usePresupuestos'; +import { + ProgramaObra, + ProgramaObraStatus, + ActividadPrograma, + CreateProgramaObraDto, + UpdateProgramaObraDto, + CreateActividadProgramaDto, + UpdateActividadProgramaDto, + SCurveDataPoint, +} from '../../../services/progress/programa-obra.api'; +import { Concepto } from '../../../services/presupuestos/presupuestos.api'; +import clsx from 'clsx'; + +const statusColors: Record = { + activo: 'bg-green-100 text-green-800', + inactivo: 'bg-gray-100 text-gray-800', + borrador: 'bg-yellow-100 text-yellow-800', + cerrado: 'bg-blue-100 text-blue-800', +}; + +const statusLabels: Record = { + activo: 'Activo', + inactivo: 'Inactivo', + borrador: 'Borrador', + cerrado: 'Cerrado', +}; + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('es-MX', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +function formatShortDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('es-MX', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + }); +} + +function calculateDurationDays(startDate: string, endDate: string): number { + const start = new Date(startDate); + const end = new Date(endDate); + const diffTime = Math.abs(end.getTime() - start.getTime()); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); +} + +export function ProgramaObraPage() { + const [selectedFraccionamientoId, setSelectedFraccionamientoId] = useState(''); + const [selectedProgramaId, setSelectedProgramaId] = useState(''); + const [showProgramaModal, setShowProgramaModal] = useState(false); + const [showActividadModal, setShowActividadModal] = useState(false); + const [editingPrograma, setEditingPrograma] = useState(null); + const [editingActividad, setEditingActividad] = useState(null); + const [parentActividadId, setParentActividadId] = useState(undefined); + const [deleteConfirmPrograma, setDeleteConfirmPrograma] = useState(null); + const [deleteConfirmActividad, setDeleteConfirmActividad] = useState<{ + programaId: string; + actividadId: string; + } | null>(null); + const [expandedActividades, setExpandedActividades] = useState>(new Set()); + + const { data: fraccionamientosData } = useFraccionamientos(); + const fraccionamientos = fraccionamientosData?.items || []; + + const { data: versionsData, isLoading: versionsLoading } = useProgramaObraVersions( + selectedFraccionamientoId + ); + const versions = versionsData || []; + + const { data: programaData, isLoading: programaLoading } = useProgramaObra(selectedProgramaId); + const programa = programaData; + + const { data: actividadesData } = useProgramaObraActividades(selectedProgramaId); + const actividades = useMemo(() => actividadesData || [], [actividadesData]); + + const { data: sCurveData } = useProgramaObraSCurve(selectedProgramaId); + const sCurve = sCurveData || []; + + const { data: statsData } = useProgramaObraStats(selectedFraccionamientoId || undefined); + + const { data: conceptosData } = useConceptos({ tipo: 'concepto' }); + const conceptos = conceptosData?.items || []; + + const createProgramaMutation = useCreateProgramaObra(); + const updateProgramaMutation = useUpdateProgramaObra(); + const deleteProgramaMutation = useDeleteProgramaObra(); + const duplicateProgramaMutation = useDuplicateProgramaObra(); + const activateProgramaMutation = useActivateProgramaObra(); + const deactivateProgramaMutation = useDeactivateProgramaObra(); + const addActividadMutation = useAddActividadPrograma(); + const updateActividadMutation = useUpdateActividadPrograma(); + const deleteActividadMutation = useDeleteActividadPrograma(); + const exportPdfMutation = useExportProgramaObraPdf(); + const exportExcelMutation = useExportProgramaObraExcel(); + + const handleSelectFraccionamiento = (fracId: string) => { + setSelectedFraccionamientoId(fracId); + setSelectedProgramaId(''); + }; + + const handleSelectPrograma = (programaId: string) => { + setSelectedProgramaId(programaId); + }; + + const handleCreatePrograma = async (formData: CreateProgramaObraDto) => { + const result = await createProgramaMutation.mutateAsync(formData); + setShowProgramaModal(false); + setSelectedProgramaId(result.id); + }; + + const handleUpdatePrograma = async (id: string, formData: UpdateProgramaObraDto) => { + await updateProgramaMutation.mutateAsync({ id, data: formData }); + setShowProgramaModal(false); + setEditingPrograma(null); + }; + + const handleDeletePrograma = async (id: string) => { + await deleteProgramaMutation.mutateAsync(id); + setDeleteConfirmPrograma(null); + if (selectedProgramaId === id) { + setSelectedProgramaId(''); + } + }; + + const handleDuplicatePrograma = async (id: string) => { + await duplicateProgramaMutation.mutateAsync(id); + }; + + const handleActivatePrograma = async (id: string) => { + await activateProgramaMutation.mutateAsync(id); + }; + + const handleDeactivatePrograma = async (id: string) => { + await deactivateProgramaMutation.mutateAsync(id); + }; + + const handleCreateActividad = async (formData: CreateActividadProgramaDto) => { + await addActividadMutation.mutateAsync({ programaId: selectedProgramaId, data: formData }); + setShowActividadModal(false); + setParentActividadId(undefined); + }; + + const handleUpdateActividad = async (actividadId: string, formData: UpdateActividadProgramaDto) => { + await updateActividadMutation.mutateAsync({ + programaId: selectedProgramaId, + actividadId, + data: formData, + }); + setShowActividadModal(false); + setEditingActividad(null); + }; + + const handleDeleteActividad = async (programaId: string, actividadId: string) => { + await deleteActividadMutation.mutateAsync({ programaId, actividadId }); + setDeleteConfirmActividad(null); + }; + + const toggleActividadExpanded = (id: string) => { + setExpandedActividades((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const openAddChildActivity = (parentId: string) => { + setParentActividadId(parentId); + setEditingActividad(null); + setShowActividadModal(true); + }; + + const openEditActivity = (actividad: ActividadPrograma) => { + setEditingActividad(actividad); + setParentActividadId(undefined); + setShowActividadModal(true); + }; + + const openNewPrograma = () => { + setEditingPrograma(null); + setShowProgramaModal(true); + }; + + const openEditPrograma = () => { + if (programa) { + setEditingPrograma(programa); + setShowProgramaModal(true); + } + }; + + const hierarchicalActividades = useMemo(() => { + const buildTree = (items: ActividadPrograma[], parentId?: string): ActividadPrograma[] => { + return items + .filter((item) => item.parentId === parentId) + .sort((a, b) => a.orden - b.orden) + .map((item) => ({ + ...item, + children: buildTree(items, item.id), + })); + }; + return buildTree(actividades, undefined); + }, [actividades]); + + return ( +
+
+
+

Programa de Obra

+

Gestion de programas y actividades de construccion

+
+
+ {selectedProgramaId && ( + <> + + + + )} + +
+
+ + {statsData && selectedFraccionamientoId && ( +
+
+
+
+

Total Programas

+

{statsData.totalProgramas}

+
+ +
+
+
+
+
+

Programas Activos

+

{statsData.programasActivos}

+
+ +
+
+
+
+
+

Avance Real Promedio

+

+ {statsData.avancePromedioReal.toFixed(1)}% +

+
+ +
+
+
+
+
+

Varianza Promedio

+

= 0 ? 'text-green-600' : 'text-red-600' + )} + > + {statsData.varianzaPromedio >= 0 ? '+' : ''} + {statsData.varianzaPromedio.toFixed(1)}% +

+
+ +
+
+
+ )} + +
+
+
+ + +
+
+ + +
+
+
+ + {!selectedFraccionamientoId ? ( +
+ Seleccione un fraccionamiento para ver los programas de obra +
+ ) : !selectedProgramaId ? ( +
+ {versions.length === 0 + ? 'No hay programas de obra. Cree uno nuevo para comenzar.' + : 'Seleccione un programa de obra para ver los detalles'} +
+ ) : programaLoading ? ( +
+ Cargando programa... +
+ ) : programa ? ( +
+ handleDuplicatePrograma(programa.id)} + onActivate={() => handleActivatePrograma(programa.id)} + onDeactivate={() => handleDeactivatePrograma(programa.id)} + onDelete={() => setDeleteConfirmPrograma(programa.id)} + isActivating={activateProgramaMutation.isPending} + isDeactivating={deactivateProgramaMutation.isPending} + /> + + + setDeleteConfirmActividad({ programaId: programa.id, actividadId }) + } + onAddRoot={() => { + setParentActividadId(undefined); + setEditingActividad(null); + setShowActividadModal(true); + }} + programaFechaInicio={programa.fechaInicio} + programaFechaFin={programa.fechaFin} + /> + + + + +
+ ) : null} + + {showProgramaModal && ( + { + setShowProgramaModal(false); + setEditingPrograma(null); + }} + onCreate={handleCreatePrograma} + onUpdate={handleUpdatePrograma} + isLoading={createProgramaMutation.isPending || updateProgramaMutation.isPending} + /> + )} + + {showActividadModal && ( + { + setShowActividadModal(false); + setEditingActividad(null); + setParentActividadId(undefined); + }} + onCreate={handleCreateActividad} + onUpdate={handleUpdateActividad} + isLoading={addActividadMutation.isPending || updateActividadMutation.isPending} + /> + )} + + {deleteConfirmPrograma && ( + handleDeletePrograma(deleteConfirmPrograma)} + onCancel={() => setDeleteConfirmPrograma(null)} + isLoading={deleteProgramaMutation.isPending} + /> + )} + + {deleteConfirmActividad && ( + + handleDeleteActividad(deleteConfirmActividad.programaId, deleteConfirmActividad.actividadId) + } + onCancel={() => setDeleteConfirmActividad(null)} + isLoading={deleteActividadMutation.isPending} + /> + )} +
+ ); +} + +interface GeneralInfoCardProps { + programa: ProgramaObra; + onEdit: () => void; + onDuplicate: () => void; + onActivate: () => void; + onDeactivate: () => void; + onDelete: () => void; + isActivating: boolean; + isDeactivating: boolean; +} + +function GeneralInfoCard({ + programa, + onEdit, + onDuplicate, + onActivate, + onDeactivate, + onDelete, + isActivating, + isDeactivating, +}: GeneralInfoCardProps) { + return ( +
+
+
+
+

{programa.nombre}

+ + {statusLabels[programa.status]} + +
+

+ Codigo: {programa.codigo} | Version: {programa.version} +

+
+
+ {programa.status === 'activo' ? ( + + ) : programa.status !== 'cerrado' ? ( + + ) : null} + + + +
+
+ + {programa.descripcion &&

{programa.descripcion}

} + +
+
+
+ + Fecha Inicio +
+

{formatDate(programa.fechaInicio)}

+
+
+
+ + Fecha Fin +
+

{formatDate(programa.fechaFin)}

+
+
+
+ + Duracion +
+

{programa.duracionDias} dias

+
+
+
+ + Actividades +
+

{programa.actividades?.length || 0}

+
+
+
+ ); +} + +interface ActividadesSectionProps { + actividades: ActividadPrograma[]; + expandedActividades: Set; + onToggleExpand: (id: string) => void; + onAddChild: (parentId: string) => void; + onEdit: (actividad: ActividadPrograma) => void; + onDelete: (actividadId: string) => void; + onAddRoot: () => void; + programaFechaInicio: string; + programaFechaFin: string; +} + +function ActividadesSection({ + actividades, + expandedActividades, + onToggleExpand, + onAddChild, + onEdit, + onDelete, + onAddRoot, +}: ActividadesSectionProps) { + const renderActividad = (actividad: ActividadPrograma, depth: number = 0): JSX.Element => { + const hasChildren = actividad.children && actividad.children.length > 0; + const isExpanded = expandedActividades.has(actividad.id); + const varianza = actividad.avanceReal - actividad.avancePlaneado; + + return ( +
+
+
+ {hasChildren && ( + + )} +
+ +
+
{actividad.wbsCode}
+
+ {actividad.nombre} + {actividad.esCritico && ( + + + + )} +
+
+ {actividad.concepto?.codigo || '-'} +
+
{formatShortDate(actividad.fechaInicioPlaneada)}
+
{formatShortDate(actividad.fechaFinPlaneada)}
+
{actividad.duracionPlaneada}d
+
+
+ {actividad.pesoRelativo.toFixed(1)}% + = 0 ? 'text-green-600' : 'text-red-600' + )} + > + ({varianza >= 0 ? '+' : ''}{varianza.toFixed(1)}%) + +
+
+
+ +
+ + + +
+
+ + {hasChildren && isExpanded && actividad.children!.map((child) => renderActividad(child, depth + 1))} +
+ ); + }; + + return ( +
+
+

Actividades del Programa

+ +
+ +
+
+
+
+
WBS
+
Actividad
+
Concepto
+
Inicio Plan.
+
Fin Plan.
+
Duracion
+
Peso %
+
+
Acciones
+
+
+ +
+ {actividades.length === 0 ? ( +
+ No hay actividades. Agregue la primera actividad para comenzar. +
+ ) : ( + actividades.map((act) => renderActividad(act)) + )} +
+
+ ); +} + +interface GanttChartProps { + actividades: ActividadPrograma[]; + programaFechaInicio: string; + programaFechaFin: string; +} + +function GanttChart({ actividades, programaFechaInicio, programaFechaFin }: GanttChartProps) { + const startDate = useMemo(() => new Date(programaFechaInicio), [programaFechaInicio]); + const endDate = useMemo(() => new Date(programaFechaFin), [programaFechaFin]); + const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + const today = new Date(); + const todayPosition = + ((today.getTime() - startDate.getTime()) / (endDate.getTime() - startDate.getTime())) * 100; + + const getBarStyle = (act: ActividadPrograma) => { + const actStart = new Date(act.fechaInicioPlaneada); + const actEnd = new Date(act.fechaFinPlaneada); + const left = + ((actStart.getTime() - startDate.getTime()) / (endDate.getTime() - startDate.getTime())) * 100; + const width = + ((actEnd.getTime() - actStart.getTime()) / (endDate.getTime() - startDate.getTime())) * 100; + return { left: `${Math.max(0, left)}%`, width: `${Math.min(100 - left, width)}%` }; + }; + + const getBarColor = (act: ActividadPrograma) => { + if (act.esCritico) return 'bg-red-500'; + const varianza = act.avanceReal - act.avancePlaneado; + if (varianza >= 0) return 'bg-green-500'; + if (varianza >= -5) return 'bg-yellow-500'; + return 'bg-orange-500'; + }; + + const flatActividades = useMemo(() => { + const flatten = (items: ActividadPrograma[], depth: number = 0): (ActividadPrograma & { depth: number })[] => { + const result: (ActividadPrograma & { depth: number })[] = []; + for (const item of items) { + result.push({ ...item, depth }); + if (item.children) { + result.push(...flatten(item.children, depth + 1)); + } + } + return result; + }; + + const buildTree = (items: ActividadPrograma[], parentId?: string): ActividadPrograma[] => { + return items + .filter((item) => item.parentId === parentId) + .sort((a, b) => a.orden - b.orden) + .map((item) => ({ + ...item, + children: buildTree(items, item.id), + })); + }; + + const tree = buildTree(actividades, undefined); + return flatten(tree); + }, [actividades]); + + const months = useMemo(() => { + const result: { label: string; width: number }[] = []; + const current = new Date(startDate); + while (current <= endDate) { + const monthStart = new Date(current.getFullYear(), current.getMonth(), 1); + const monthEnd = new Date(current.getFullYear(), current.getMonth() + 1, 0); + const effectiveStart = monthStart < startDate ? startDate : monthStart; + const effectiveEnd = monthEnd > endDate ? endDate : monthEnd; + const daysInRange = Math.ceil( + (effectiveEnd.getTime() - effectiveStart.getTime()) / (1000 * 60 * 60 * 24) + ) + 1; + const width = (daysInRange / totalDays) * 100; + result.push({ + label: current.toLocaleDateString('es-MX', { month: 'short', year: '2-digit' }), + width, + }); + current.setMonth(current.getMonth() + 1); + } + return result; + }, [startDate, endDate, totalDays]); + + return ( +
+
+

Diagrama de Gantt

+
+
+
+ A tiempo +
+
+
+ Leve retraso +
+
+
+ Retraso +
+
+
+ Ruta Critica +
+
+
+ +
+
+
+
+ Actividad +
+
+ {months.map((month, idx) => ( +
+ {month.label} +
+ ))} +
+
+ +
+ {todayPosition >= 0 && todayPosition <= 100 && ( +
+
+ Hoy +
+
+ )} + + {flatActividades.slice(0, 15).map((act) => { + const barStyle = getBarStyle(act); + const barColor = getBarColor(act); + + return ( +
+
+ {act.nombre} +
+
+
+
+
+
+
+ ); + })} + + {flatActividades.length > 15 && ( +
+ + {flatActividades.length - 15} actividades mas +
+ )} +
+
+
+
+ ); +} + +interface SCurveChartProps { + sCurveData: SCurveDataPoint[]; +} + +function SCurveChart({ sCurveData }: SCurveChartProps) { + if (sCurveData.length === 0) { + return ( +
+

Curva S

+
+ No hay datos suficientes para mostrar la curva S +
+
+ ); + } + + const chartHeight = 200; + const chartWidth = 100; + + const getY = (value: number) => chartHeight - (value / 100) * chartHeight; + + const plannedPath = sCurveData + .map((d, i) => { + const x = (i / (sCurveData.length - 1)) * chartWidth; + const y = getY(d.avancePlaneadoAcumulado); + return `${i === 0 ? 'M' : 'L'} ${x} ${y}`; + }) + .join(' '); + + const actualPath = sCurveData + .map((d, i) => { + const x = (i / (sCurveData.length - 1)) * chartWidth; + const y = getY(d.avanceRealAcumulado); + return `${i === 0 ? 'M' : 'L'} ${x} ${y}`; + }) + .join(' '); + + const lastPoint = sCurveData[sCurveData.length - 1]; + const varianza = lastPoint ? lastPoint.varianza : 0; + + return ( +
+
+

Curva S (Avance Acumulado)

+
+
+
+ Planeado +
+
+
+ Real +
+
= 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' + )} + > + Varianza: {varianza >= 0 ? '+' : ''} + {varianza.toFixed(1)}% +
+
+
+ +
+ + {[0, 25, 50, 75, 100].map((v) => ( + + + + {v}% + + + ))} + + + + + + +
+ {formatShortDate(sCurveData[0]?.fecha || '')} + {formatShortDate(sCurveData[Math.floor(sCurveData.length / 2)]?.fecha || '')} + {formatShortDate(sCurveData[sCurveData.length - 1]?.fecha || '')} +
+
+ +
+
+

Avance Planeado

+

+ {lastPoint?.avancePlaneadoAcumulado.toFixed(1) || 0}% +

+
+
+

Avance Real

+

+ {lastPoint?.avanceRealAcumulado.toFixed(1) || 0}% +

+
+
= 0 ? 'bg-green-50' : 'bg-red-50' + )} + > +

= 0 ? 'text-green-600' : 'text-red-600')}> + Varianza +

+

= 0 ? 'text-green-700' : 'text-red-700' + )} + > + {varianza >= 0 ? '+' : ''} + {varianza.toFixed(1)}% +

+
+
+
+ ); +} + +interface ProgramaModalProps { + programa: ProgramaObra | null; + fraccionamientoId: string; + onClose: () => void; + onCreate: (data: CreateProgramaObraDto) => Promise; + onUpdate: (id: string, data: UpdateProgramaObraDto) => Promise; + isLoading: boolean; +} + +function ProgramaModal({ + programa, + fraccionamientoId, + onClose, + onCreate, + onUpdate, + isLoading, +}: ProgramaModalProps) { + const [codigo, setCodigo] = useState(programa?.codigo || ''); + const [nombre, setNombre] = useState(programa?.nombre || ''); + const [descripcion, setDescripcion] = useState(programa?.descripcion || ''); + const [fechaInicio, setFechaInicio] = useState( + programa?.fechaInicio?.split('T')[0] || new Date().toISOString().split('T')[0] + ); + const [fechaFin, setFechaFin] = useState( + programa?.fechaFin?.split('T')[0] || + new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + ); + const [status, setStatus] = useState(programa?.status || 'borrador'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (programa) { + await onUpdate(programa.id, { + codigo, + nombre, + descripcion: descripcion || undefined, + fechaInicio, + fechaFin, + status, + }); + } else { + await onCreate({ + fraccionamientoId, + codigo, + nombre, + descripcion: descripcion || undefined, + fechaInicio, + fechaFin, + status, + }); + } + }; + + return ( +
+
+

+ {programa ? 'Editar Programa de Obra' : 'Nuevo Programa de Obra'} +

+
+
+
+ + setCodigo(e.target.value)} + /> +
+
+ + +
+
+ +
+ + setNombre(e.target.value)} + /> +
+ +
+ +