workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-005-control-obra/especificaciones/ET-OBRA-001-frontend.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

37 KiB

ET-OBRA-001: Especificación Técnica Frontend - Control de Obra

1. Información General

Campo Valor
Módulo MAI-005 - Control de Obra
Vertical Construcción
Tipo Especificación Técnica - Frontend
Versión 1.0.0
Fecha 2025-12-06
Estado Borrador

2. Objetivo

Definir la arquitectura, componentes y patrones de desarrollo del frontend para el módulo de Control de Obra, enfocado en la gestión de avance físico, financiero, reportes de obra y seguimiento ejecutivo.

3. Stack Tecnológico

3.1 Core Technologies

Tecnología Versión Propósito
React 18.3.x UI Library
TypeScript 5.3.x Type Safety
Vite 5.0.x Build Tool & Dev Server
Zustand 4.5.x State Management
React Router 6.20.x Routing & Navigation

3.2 UI & Styling

Librería Versión Uso
Tailwind CSS 3.4.x Utility-first CSS
Headless UI 2.0.x Accessible Components
Lucide React 0.300.x Icon System
Framer Motion 11.0.x Animations

3.3 Data Visualization

Librería Versión Uso
Chart.js 4.4.x Charting Library
react-chartjs-2 5.2.x React Wrapper
Leaflet 1.9.x Interactive Maps
react-leaflet 4.2.x React Integration

3.4 Real-time & Networking

Librería Versión Uso
Socket.io Client 4.6.x WebSocket Communication
Axios 1.6.x HTTP Client
React Query 5.17.x Server State Management

3.5 Forms & Validation

Librería Versión Uso
React Hook Form 7.49.x Form Management
Zod 3.22.x Schema Validation
date-fns 3.0.x Date Utilities

3.6 File Management

Librería Versión Uso
React Dropzone 14.2.x File Upload
react-image-lightbox 5.1.x Photo Gallery
react-pdf 7.7.x PDF Preview

4. Arquitectura Frontend

4.1 Estructura de Directorios

src/
├── features/
│   ├── progress/              # Avance de Obra
│   │   ├── components/
│   │   │   ├── ProgressForm.tsx
│   │   │   ├── CurveSChart.tsx
│   │   │   ├── EVMDashboard.tsx
│   │   │   ├── ProgressTable.tsx
│   │   │   └── ProgressFilters.tsx
│   │   ├── hooks/
│   │   │   ├── useProgress.ts
│   │   │   ├── useCurveS.ts
│   │   │   └── useEVM.ts
│   │   ├── stores/
│   │   │   └── progressStore.ts
│   │   ├── services/
│   │   │   └── progressService.ts
│   │   ├── types/
│   │   │   └── progress.types.ts
│   │   └── utils/
│   │       └── evmCalculations.ts
│   │
│   ├── work-log/              # Bitácora de Obra
│   │   ├── components/
│   │   │   ├── WorkLogForm.tsx
│   │   │   ├── WorkLogTimeline.tsx
│   │   │   ├── PhotoGallery.tsx
│   │   │   ├── PhotoUploader.tsx
│   │   │   ├── WeatherWidget.tsx
│   │   │   └── WorkLogFilters.tsx
│   │   ├── hooks/
│   │   │   ├── useWorkLog.ts
│   │   │   ├── usePhotoUpload.ts
│   │   │   └── useWeather.ts
│   │   ├── stores/
│   │   │   └── workLogStore.ts
│   │   ├── services/
│   │   │   └── workLogService.ts
│   │   └── types/
│   │       └── workLog.types.ts
│   │
│   ├── estimations/           # Estimaciones
│   │   ├── components/
│   │   │   ├── EstimationForm.tsx
│   │   │   ├── EstimationTable.tsx
│   │   │   ├── ConceptSelector.tsx
│   │   │   ├── EstimationSummary.tsx
│   │   │   └── EstimationHistory.tsx
│   │   ├── hooks/
│   │   │   ├── useEstimation.ts
│   │   │   └── useEstimationCalc.ts
│   │   ├── stores/
│   │   │   └── estimationStore.ts
│   │   ├── services/
│   │   │   └── estimationService.ts
│   │   └── types/
│   │       └── estimation.types.ts
│   │
│   └── dashboard/             # Dashboard Ejecutivo
│       ├── components/
│       │   ├── ExecutiveDashboard.tsx
│       │   ├── ProgressKPIs.tsx
│       │   ├── FinancialKPIs.tsx
│       │   ├── ProjectMap.tsx
│       │   ├── CriticalAlerts.tsx
│       │   └── RecentActivity.tsx
│       ├── hooks/
│       │   ├── useDashboard.ts
│       │   ├── useRealtime.ts
│       │   └── useKPIs.ts
│       ├── stores/
│       │   └── dashboardStore.ts
│       └── services/
│           └── dashboardService.ts
│
├── shared/
│   ├── components/
│   │   ├── charts/
│   │   │   ├── LineChart.tsx
│   │   │   ├── BarChart.tsx
│   │   │   ├── PieChart.tsx
│   │   │   └── GaugeChart.tsx
│   │   ├── maps/
│   │   │   ├── ProjectMap.tsx
│   │   │   ├── LocationMarker.tsx
│   │   │   └── MapControls.tsx
│   │   ├── forms/
│   │   │   ├── FormInput.tsx
│   │   │   ├── FormSelect.tsx
│   │   │   ├── FormDatePicker.tsx
│   │   │   └── FormFileUpload.tsx
│   │   └── layout/
│   │       ├── PageHeader.tsx
│   │       ├── Card.tsx
│   │       └── Tabs.tsx
│   ├── hooks/
│   │   ├── useWebSocket.ts
│   │   ├── useFileUpload.ts
│   │   └── useExport.ts
│   ├── utils/
│   │   ├── chartConfig.ts
│   │   ├── mapConfig.ts
│   │   ├── formatters.ts
│   │   └── validators.ts
│   └── types/
│       └── common.types.ts
│
├── config/
│   ├── chartDefaults.ts
│   ├── mapDefaults.ts
│   └── websocket.config.ts
│
└── App.tsx

4.2 Patrón de Feature Modules

Cada feature module es autónomo e incluye:

  • Components: Componentes React específicos del feature
  • Hooks: Custom hooks para lógica reutilizable
  • Stores: Estado global con Zustand
  • Services: Comunicación con API
  • Types: Definiciones TypeScript
  • Utils: Funciones auxiliares

5. Componentes Principales

5.1 Progress Module

5.1.1 ProgressForm

Propósito: Formulario para registrar avance de conceptos

interface ProgressFormProps {
  projectId: string;
  estimationId?: string;
  initialData?: ProgressEntry;
  onSubmit: (data: ProgressFormData) => Promise<void>;
  onCancel: () => void;
}

interface ProgressFormData {
  concept_id: string;
  period_start: Date;
  period_end: Date;
  quantity_executed: number;
  photos: File[];
  observations: string;
  location?: {
    lat: number;
    lng: number;
  };
}

Features:

  • Selección de conceptos del catálogo
  • Cálculo automático de importes
  • Upload de fotos con preview
  • Geolocalización opcional
  • Validación de cantidades vs. estimado

5.1.2 CurveSChart

Propósito: Visualización de Curva S (Planned vs Actual)

interface CurveSChartProps {
  projectId: string;
  dateRange?: { start: Date; end: Date };
  showBaseline?: boolean;
  showForecast?: boolean;
}

interface CurveSData {
  date: Date;
  planned_cumulative: number;
  actual_cumulative: number;
  forecast_cumulative?: number;
}

Chart Configuration:

  • Tipo: Line Chart (Chart.js)
  • Datasets:
    • Planned: Línea azul continua
    • Actual: Línea verde continua con markers
    • Forecast: Línea naranja punteada
  • Interactividad:
    • Tooltip con detalles
    • Zoom/Pan
    • Export PNG/PDF

5.1.3 EVMDashboard

Propósito: Dashboard de Earned Value Management

interface EVMMetrics {
  pv: number;      // Planned Value
  ev: number;      // Earned Value
  ac: number;      // Actual Cost
  sv: number;      // Schedule Variance
  cv: number;      // Cost Variance
  spi: number;     // Schedule Performance Index
  cpi: number;     // Cost Performance Index
  eac: number;     // Estimate at Completion
  etc: number;     // Estimate to Complete
  vac: number;     // Variance at Completion
}

Visualizations:

  • Gauge charts para SPI/CPI
  • Bar chart comparativo PV/EV/AC
  • KPI cards con varianzas
  • Forecast trends

5.2 Work Log Module

5.2.1 WorkLogForm

Propósito: Formulario para bitácora diaria

interface WorkLogFormProps {
  projectId: string;
  date: Date;
  initialData?: WorkLog;
  onSubmit: (data: WorkLogFormData) => Promise<void>;
}

interface WorkLogFormData {
  date: Date;
  weather: {
    condition: WeatherCondition;
    temperature: number;
    humidity: number;
  };
  workforce: {
    category: string;
    count: number;
  }[];
  equipment: {
    type: string;
    hours: number;
  }[];
  activities: string;
  incidents: string;
  materials_received: {
    material: string;
    quantity: number;
  }[];
  photos: File[];
  visitors?: {
    name: string;
    company: string;
    purpose: string;
  }[];
}

5.2.2 WorkLogTimeline

Propósito: Vista cronológica de bitácoras

interface WorkLogTimelineProps {
  projectId: string;
  dateRange: { start: Date; end: Date };
  filters?: WorkLogFilters;
  groupBy?: 'day' | 'week' | 'month';
}

Features:

  • Timeline vertical con cards
  • Filtros por fecha, clima, actividad
  • Search full-text
  • Export to PDF

5.2.3 PhotoGallery

Propósito: Galería de fotos de obra

interface PhotoGalleryProps {
  photos: WorkPhoto[];
  onPhotoClick: (photo: WorkPhoto) => void;
  layout?: 'grid' | 'masonry';
  filterBy?: {
    date?: Date;
    tags?: string[];
    location?: string;
  };
}

interface WorkPhoto {
  id: string;
  url: string;
  thumbnail_url: string;
  caption: string;
  date: Date;
  location?: {
    lat: number;
    lng: number;
  };
  tags: string[];
  work_log_id: string;
}

Features:

  • Lazy loading de imágenes
  • Lightbox para vista completa
  • Filtros y búsqueda
  • Download individual/batch
  • Geolocalización en mapa

5.3 Estimations Module

5.3.1 EstimationForm

Propósito: Formulario para crear estimaciones

interface EstimationFormProps {
  projectId: string;
  estimationNumber?: number;
  initialData?: Estimation;
  onSubmit: (data: EstimationFormData) => Promise<void>;
}

interface EstimationFormData {
  estimation_number: number;
  period_start: Date;
  period_end: Date;
  concepts: EstimationConcept[];
  retention_percentage: number;
  advance_payment_deduction: number;
  notes: string;
}

interface EstimationConcept {
  concept_id: string;
  quantity_previous: number;
  quantity_current: number;
  quantity_total: number;
  amount_current: number;
}

5.3.2 EstimationTable

Propósito: Tabla interactiva de conceptos

interface EstimationTableProps {
  concepts: EstimationConcept[];
  editable?: boolean;
  onConceptUpdate?: (conceptId: string, data: Partial<EstimationConcept>) => void;
  showSummary?: boolean;
}

Features:

  • Edición inline de cantidades
  • Cálculos automáticos
  • Totales y subtotales
  • Validación de cantidades
  • Export Excel/PDF
  • Resaltado de variaciones

5.4 Dashboard Module

5.4.1 ExecutiveDashboard

Propósito: Dashboard ejecutivo en tiempo real

interface ExecutiveDashboardProps {
  projectId?: string;
  portfolioView?: boolean;
  refreshInterval?: number;
}

Layout:

┌─────────────────────────────────────────────────┐
│  KPIs Row                                       │
│  [Progress] [Cost] [Schedule] [Quality]        │
├─────────────────────────────────────────────────┤
│  Charts Row                                     │
│  [Curve S]          [EVM Chart]                │
├─────────────────────────────────────────────────┤
│  Maps & Alerts                                  │
│  [Project Map]      [Critical Alerts]          │
├─────────────────────────────────────────────────┤
│  Activity & Status                              │
│  [Recent Activity]  [Project Status]           │
└─────────────────────────────────────────────────┘

5.4.2 ProjectMap

Propósito: Mapa interactivo de proyectos

interface ProjectMapProps {
  projects: ProjectLocation[];
  selectedProject?: string;
  onProjectSelect: (projectId: string) => void;
  showProgress?: boolean;
  showAlerts?: boolean;
}

interface ProjectLocation {
  id: string;
  name: string;
  coordinates: [number, number];
  progress_percent: number;
  status: ProjectStatus;
  alerts_count: number;
}

Leaflet Configuration:

  • Base layer: OpenStreetMap
  • Custom markers por estado de proyecto
  • Popup con KPIs del proyecto
  • Cluster para múltiples proyectos
  • Controls: Zoom, fullscreen, layer selector

6. State Management

6.1 Zustand Stores

6.1.1 Progress Store

interface ProgressState {
  // State
  currentProject: string | null;
  progressEntries: ProgressEntry[];
  curveData: CurveSData[];
  evmMetrics: EVMMetrics | null;
  filters: ProgressFilters;

  // Actions
  setProject: (projectId: string) => void;
  loadProgressEntries: (projectId: string) => Promise<void>;
  addProgressEntry: (entry: ProgressEntry) => Promise<void>;
  updateProgressEntry: (id: string, data: Partial<ProgressEntry>) => Promise<void>;
  loadCurveData: (projectId: string) => Promise<void>;
  loadEVMMetrics: (projectId: string) => Promise<void>;
  setFilters: (filters: Partial<ProgressFilters>) => void;
  clearFilters: () => void;
}

const useProgressStore = create<ProgressState>((set, get) => ({
  // Implementation
}));

6.1.2 Dashboard Store

interface DashboardState {
  // State
  kpis: DashboardKPIs | null;
  realtimeData: RealtimeUpdate[];
  alerts: Alert[];
  isConnected: boolean;
  lastUpdate: Date | null;

  // Actions
  loadKPIs: (projectId?: string) => Promise<void>;
  subscribeToUpdates: (projectId?: string) => void;
  unsubscribeFromUpdates: () => void;
  addRealtimeUpdate: (update: RealtimeUpdate) => void;
  acknowledgeAlert: (alertId: string) => Promise<void>;
}

7. WebSocket Integration

7.1 Socket.io Client Setup

// config/websocket.config.ts
export const SOCKET_CONFIG = {
  url: import.meta.env.VITE_WS_URL || 'http://localhost:3000',
  options: {
    transports: ['websocket', 'polling'],
    reconnection: true,
    reconnectionDelay: 1000,
    reconnectionDelayMax: 5000,
    reconnectionAttempts: 5,
  },
};

// shared/hooks/useWebSocket.ts
export const useWebSocket = (namespace: string = '/') => {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const newSocket = io(`${SOCKET_CONFIG.url}${namespace}`, SOCKET_CONFIG.options);

    newSocket.on('connect', () => setIsConnected(true));
    newSocket.on('disconnect', () => setIsConnected(false));

    setSocket(newSocket);

    return () => {
      newSocket.close();
    };
  }, [namespace]);

  return { socket, isConnected };
};

7.2 Real-time Events

// Dashboard realtime updates
socket.on('progress:updated', (data: ProgressUpdate) => {
  dashboardStore.getState().addRealtimeUpdate(data);
  progressStore.getState().loadProgressEntries(data.project_id);
});

socket.on('estimation:created', (data: EstimationCreated) => {
  dashboardStore.getState().addRealtimeUpdate(data);
  estimationStore.getState().loadEstimations(data.project_id);
});

socket.on('alert:created', (data: Alert) => {
  dashboardStore.getState().addRealtimeUpdate(data);
  // Show toast notification
});

socket.on('kpi:updated', (data: KPIUpdate) => {
  dashboardStore.getState().loadKPIs(data.project_id);
});

8. Chart.js Configurations

8.1 Curve S Chart

// shared/utils/chartConfig.ts
export const curveSChartConfig = (data: CurveSData[]): ChartConfiguration => ({
  type: 'line',
  data: {
    labels: data.map(d => format(d.date, 'MMM yyyy')),
    datasets: [
      {
        label: 'Planeado',
        data: data.map(d => d.planned_cumulative),
        borderColor: 'rgb(59, 130, 246)',
        backgroundColor: 'rgba(59, 130, 246, 0.1)',
        tension: 0.4,
      },
      {
        label: 'Real',
        data: data.map(d => d.actual_cumulative),
        borderColor: 'rgb(34, 197, 94)',
        backgroundColor: 'rgba(34, 197, 94, 0.1)',
        tension: 0.4,
        pointRadius: 4,
      },
      {
        label: 'Pronóstico',
        data: data.map(d => d.forecast_cumulative),
        borderColor: 'rgb(249, 115, 22)',
        backgroundColor: 'rgba(249, 115, 22, 0.1)',
        borderDash: [5, 5],
        tension: 0.4,
      },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: {
        position: 'top',
      },
      tooltip: {
        mode: 'index',
        intersect: false,
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${formatCurrency(context.parsed.y)}`;
          },
        },
      },
      zoom: {
        zoom: {
          wheel: { enabled: true },
          pinch: { enabled: true },
          mode: 'x',
        },
        pan: {
          enabled: true,
          mode: 'x',
        },
      },
    },
    scales: {
      y: {
        beginAtZero: true,
        ticks: {
          callback: (value) => formatCurrency(value),
        },
      },
    },
  },
});

8.2 EVM Performance Chart

export const evmChartConfig = (metrics: EVMMetrics): ChartConfiguration => ({
  type: 'bar',
  data: {
    labels: ['Valor'],
    datasets: [
      {
        label: 'Valor Planeado (PV)',
        data: [metrics.pv],
        backgroundColor: 'rgba(59, 130, 246, 0.7)',
      },
      {
        label: 'Valor Ganado (EV)',
        data: [metrics.ev],
        backgroundColor: 'rgba(34, 197, 94, 0.7)',
      },
      {
        label: 'Costo Real (AC)',
        data: [metrics.ac],
        backgroundColor: 'rgba(239, 68, 68, 0.7)',
      },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    indexAxis: 'y',
    plugins: {
      legend: {
        position: 'top',
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${formatCurrency(context.parsed.x)}`;
          },
        },
      },
    },
    scales: {
      x: {
        beginAtZero: true,
        ticks: {
          callback: (value) => formatCurrency(value),
        },
      },
    },
  },
});

8.3 Performance Index Gauges

export const gaugeChartConfig = (
  value: number,
  label: string,
  thresholds: { warning: number; danger: number }
): ChartConfiguration => ({
  type: 'doughnut',
  data: {
    datasets: [
      {
        data: [value, 2 - value],
        backgroundColor: [
          getGaugeColor(value, thresholds),
          'rgba(229, 231, 235, 0.3)',
        ],
        borderWidth: 0,
      },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    rotation: -90,
    circumference: 180,
    cutout: '75%',
    plugins: {
      legend: { display: false },
      tooltip: { enabled: false },
    },
  },
});

9. Leaflet Integration

9.1 Map Configuration

// shared/utils/mapConfig.ts
export const MAP_CONFIG = {
  defaultCenter: [19.4326, -99.1332] as [number, number], // CDMX
  defaultZoom: 13,
  minZoom: 5,
  maxZoom: 18,
  tileLayer: {
    url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
  },
};

export const getMarkerIcon = (status: ProjectStatus) => {
  const color = {
    active: '#22c55e',
    warning: '#f59e0b',
    critical: '#ef4444',
    completed: '#6366f1',
  }[status];

  return new L.DivIcon({
    html: `<div style="background-color: ${color}; width: 32px; height: 32px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
    className: 'custom-marker',
    iconSize: [32, 32],
    iconAnchor: [16, 16],
  });
};

9.2 ProjectMap Component

import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';

export const ProjectMap: React.FC<ProjectMapProps> = ({
  projects,
  selectedProject,
  onProjectSelect,
  showProgress = true,
  showAlerts = true,
}) => {
  return (
    <MapContainer
      center={MAP_CONFIG.defaultCenter}
      zoom={MAP_CONFIG.defaultZoom}
      style={{ height: '100%', width: '100%' }}
    >
      <TileLayer
        url={MAP_CONFIG.tileLayer.url}
        attribution={MAP_CONFIG.tileLayer.attribution}
      />

      {projects.map((project) => (
        <Marker
          key={project.id}
          position={project.coordinates}
          icon={getMarkerIcon(project.status)}
          eventHandlers={{
            click: () => onProjectSelect(project.id),
          }}
        >
          <Popup>
            <div className="p-2">
              <h3 className="font-bold text-sm">{project.name}</h3>
              {showProgress && (
                <div className="mt-2">
                  <p className="text-xs text-gray-600">Avance</p>
                  <div className="w-full bg-gray-200 rounded-full h-2">
                    <div
                      className="bg-green-500 h-2 rounded-full"
                      style={{ width: `${project.progress_percent}%` }}
                    />
                  </div>
                  <p className="text-xs text-right">{project.progress_percent}%</p>
                </div>
              )}
              {showAlerts && project.alerts_count > 0 && (
                <div className="mt-2 flex items-center text-red-600">
                  <AlertTriangle size={14} className="mr-1" />
                  <span className="text-xs">{project.alerts_count} alertas</span>
                </div>
              )}
            </div>
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  );
};

10. Custom Hooks

10.1 useProgress

export const useProgress = (projectId: string) => {
  const store = useProgressStore();

  const { data: entries, isLoading, error } = useQuery({
    queryKey: ['progress', projectId],
    queryFn: () => progressService.getProgressEntries(projectId),
    enabled: !!projectId,
  });

  const addEntry = useMutation({
    mutationFn: (data: ProgressFormData) =>
      progressService.createProgressEntry(projectId, data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['progress', projectId] });
      store.loadProgressEntries(projectId);
    },
  });

  return {
    entries,
    isLoading,
    error,
    addEntry: addEntry.mutate,
    isAdding: addEntry.isPending,
  };
};

10.2 useCurveS

export const useCurveS = (projectId: string, dateRange?: DateRange) => {
  const { data, isLoading } = useQuery({
    queryKey: ['curve-s', projectId, dateRange],
    queryFn: () => progressService.getCurveSData(projectId, dateRange),
    enabled: !!projectId,
  });

  const chartConfig = useMemo(() => {
    if (!data) return null;
    return curveSChartConfig(data);
  }, [data]);

  return { data, chartConfig, isLoading };
};

10.3 useRealtime

export const useRealtime = (projectId?: string) => {
  const { socket, isConnected } = useWebSocket('/dashboard');
  const dashboardStore = useDashboardStore();

  useEffect(() => {
    if (!socket || !isConnected) return;

    if (projectId) {
      socket.emit('subscribe:project', projectId);
    }

    socket.on('progress:updated', dashboardStore.addRealtimeUpdate);
    socket.on('estimation:created', dashboardStore.addRealtimeUpdate);
    socket.on('alert:created', dashboardStore.addRealtimeUpdate);
    socket.on('kpi:updated', () => dashboardStore.loadKPIs(projectId));

    return () => {
      socket.off('progress:updated');
      socket.off('estimation:created');
      socket.off('alert:created');
      socket.off('kpi:updated');

      if (projectId) {
        socket.emit('unsubscribe:project', projectId);
      }
    };
  }, [socket, isConnected, projectId]);

  return { isConnected };
};

10.4 usePhotoUpload

export const usePhotoUpload = () => {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);

  const uploadPhotos = async (files: File[], workLogId: string) => {
    setUploading(true);
    setProgress(0);

    const formData = new FormData();
    files.forEach((file) => {
      formData.append('photos', file);
    });
    formData.append('work_log_id', workLogId);

    try {
      const response = await axios.post('/api/work-logs/photos', formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
        onUploadProgress: (progressEvent) => {
          const percent = Math.round(
            (progressEvent.loaded * 100) / (progressEvent.total || 1)
          );
          setProgress(percent);
        },
      });

      return response.data;
    } finally {
      setUploading(false);
      setProgress(0);
    }
  };

  return { uploadPhotos, uploading, progress };
};

11. Services Layer

11.1 Progress Service

// features/progress/services/progressService.ts
class ProgressService {
  private baseUrl = '/api/progress';

  async getProgressEntries(projectId: string, filters?: ProgressFilters) {
    const params = new URLSearchParams();
    if (filters?.dateFrom) params.append('date_from', filters.dateFrom.toISOString());
    if (filters?.dateTo) params.append('date_to', filters.dateTo.toISOString());

    const response = await axios.get<ProgressEntry[]>(
      `${this.baseUrl}/projects/${projectId}/entries?${params}`
    );
    return response.data;
  }

  async createProgressEntry(projectId: string, data: ProgressFormData) {
    const formData = new FormData();
    formData.append('concept_id', data.concept_id);
    formData.append('period_start', data.period_start.toISOString());
    formData.append('period_end', data.period_end.toISOString());
    formData.append('quantity_executed', data.quantity_executed.toString());
    formData.append('observations', data.observations);

    data.photos.forEach((photo) => {
      formData.append('photos', photo);
    });

    const response = await axios.post<ProgressEntry>(
      `${this.baseUrl}/projects/${projectId}/entries`,
      formData
    );
    return response.data;
  }

  async getCurveSData(projectId: string, dateRange?: DateRange) {
    const params = new URLSearchParams();
    if (dateRange) {
      params.append('start_date', dateRange.start.toISOString());
      params.append('end_date', dateRange.end.toISOString());
    }

    const response = await axios.get<CurveSData[]>(
      `${this.baseUrl}/projects/${projectId}/curve-s?${params}`
    );
    return response.data;
  }

  async getEVMMetrics(projectId: string) {
    const response = await axios.get<EVMMetrics>(
      `${this.baseUrl}/projects/${projectId}/evm`
    );
    return response.data;
  }
}

export const progressService = new ProgressService();

11.2 Dashboard Service

class DashboardService {
  private baseUrl = '/api/dashboard';

  async getKPIs(projectId?: string) {
    const url = projectId
      ? `${this.baseUrl}/projects/${projectId}/kpis`
      : `${this.baseUrl}/portfolio/kpis`;

    const response = await axios.get<DashboardKPIs>(url);
    return response.data;
  }

  async getAlerts(projectId?: string, severity?: AlertSeverity) {
    const params = new URLSearchParams();
    if (projectId) params.append('project_id', projectId);
    if (severity) params.append('severity', severity);

    const response = await axios.get<Alert[]>(
      `${this.baseUrl}/alerts?${params}`
    );
    return response.data;
  }

  async acknowledgeAlert(alertId: string) {
    await axios.post(`${this.baseUrl}/alerts/${alertId}/acknowledge`);
  }
}

export const dashboardService = new DashboardService();

12. Type Definitions

12.1 Progress Types

// features/progress/types/progress.types.ts
export interface ProgressEntry {
  id: string;
  project_id: string;
  concept_id: string;
  concept_name: string;
  unit: string;
  unit_price: number;
  period_start: Date;
  period_end: Date;
  quantity_previous: number;
  quantity_current: number;
  quantity_total: number;
  amount_current: number;
  amount_total: number;
  progress_percent: number;
  photos: Photo[];
  location?: Location;
  observations: string;
  created_by: string;
  created_at: Date;
}

export interface CurveSData {
  date: Date;
  planned_cumulative: number;
  actual_cumulative: number;
  forecast_cumulative?: number;
}

export interface EVMMetrics {
  pv: number;
  ev: number;
  ac: number;
  sv: number;
  cv: number;
  spi: number;
  cpi: number;
  bac: number;
  eac: number;
  etc: number;
  vac: number;
  tcpi: number;
}

export interface ProgressFilters {
  dateFrom?: Date;
  dateTo?: Date;
  conceptIds?: string[];
  minProgress?: number;
  maxProgress?: number;
}

12.2 Work Log Types

export interface WorkLog {
  id: string;
  project_id: string;
  date: Date;
  weather: Weather;
  workforce: WorkforceEntry[];
  equipment: EquipmentEntry[];
  activities: string;
  incidents: string;
  materials_received: MaterialEntry[];
  visitors: Visitor[];
  photos: Photo[];
  created_by: string;
  created_at: Date;
}

export interface Weather {
  condition: 'sunny' | 'cloudy' | 'rainy' | 'stormy';
  temperature: number;
  humidity: number;
  wind_speed?: number;
}

export interface WorkforceEntry {
  category: string;
  count: number;
}

export interface EquipmentEntry {
  type: string;
  hours: number;
}

export interface Photo {
  id: string;
  url: string;
  thumbnail_url: string;
  caption: string;
  location?: Location;
  tags: string[];
  uploaded_at: Date;
}

12.3 Dashboard Types

export interface DashboardKPIs {
  progress: {
    physical_percent: number;
    financial_percent: number;
    schedule_variance_days: number;
    cost_variance_percent: number;
  };
  financial: {
    total_budget: number;
    spent_to_date: number;
    committed: number;
    available: number;
  };
  schedule: {
    start_date: Date;
    planned_end_date: Date;
    forecast_end_date: Date;
    days_remaining: number;
  };
  quality: {
    inspections_passed: number;
    inspections_failed: number;
    pending_corrections: number;
  };
}

export interface Alert {
  id: string;
  project_id: string;
  severity: 'info' | 'warning' | 'critical';
  category: string;
  title: string;
  description: string;
  created_at: Date;
  acknowledged: boolean;
  acknowledged_by?: string;
  acknowledged_at?: Date;
}

export interface RealtimeUpdate {
  type: 'progress' | 'estimation' | 'alert' | 'kpi';
  project_id: string;
  data: any;
  timestamp: Date;
}

13. Utilities

13.1 EVM Calculations

// features/progress/utils/evmCalculations.ts
export const calculateEVM = (
  bac: number,
  plannedProgress: number,
  actualProgress: number,
  actualCost: number
): EVMMetrics => {
  const pv = bac * (plannedProgress / 100);
  const ev = bac * (actualProgress / 100);
  const ac = actualCost;

  const sv = ev - pv;
  const cv = ev - ac;

  const spi = pv > 0 ? ev / pv : 0;
  const cpi = ac > 0 ? ev / ac : 0;

  const eac = cpi > 0 ? bac / cpi : bac;
  const etc = eac - ac;
  const vac = bac - eac;
  const tcpi = (bac - ev) / (bac - ac);

  return {
    pv,
    ev,
    ac,
    sv,
    cv,
    spi,
    cpi,
    bac,
    eac,
    etc,
    vac,
    tcpi,
  };
};

export const getPerformanceStatus = (index: number): PerformanceStatus => {
  if (index >= 0.95 && index <= 1.05) return 'good';
  if (index >= 0.85 && index < 0.95) return 'warning';
  if (index > 1.05 && index <= 1.15) return 'warning';
  return 'critical';
};

13.2 Formatters

// shared/utils/formatters.ts
export const formatCurrency = (value: number): string => {
  return new Intl.NumberFormat('es-MX', {
    style: 'currency',
    currency: 'MXN',
  }).format(value);
};

export const formatPercent = (value: number, decimals: number = 1): string => {
  return `${value.toFixed(decimals)}%`;
};

export const formatNumber = (value: number, decimals: number = 2): string => {
  return new Intl.NumberFormat('es-MX', {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  }).format(value);
};

export const formatDate = (date: Date, formatStr: string = 'dd/MM/yyyy'): string => {
  return format(date, formatStr, { locale: es });
};

14. Performance Optimization

14.1 Code Splitting

// Lazy loading de feature modules
const ProgressModule = lazy(() => import('./features/progress'));
const WorkLogModule = lazy(() => import('./features/work-log'));
const EstimationsModule = lazy(() => import('./features/estimations'));
const DashboardModule = lazy(() => import('./features/dashboard'));

// En router
<Suspense fallback={<LoadingSpinner />}>
  <Routes>
    <Route path="/progress/*" element={<ProgressModule />} />
    <Route path="/work-log/*" element={<WorkLogModule />} />
    <Route path="/estimations/*" element={<EstimationsModule />} />
    <Route path="/dashboard/*" element={<DashboardModule />} />
  </Routes>
</Suspense>

14.2 Image Optimization

// Lazy loading de imágenes en PhotoGallery
import { LazyLoadImage } from 'react-lazy-load-image-component';
import 'react-lazy-load-image-component/src/effects/blur.css';

<LazyLoadImage
  src={photo.url}
  placeholderSrc={photo.thumbnail_url}
  effect="blur"
  alt={photo.caption}
/>

14.3 Chart Memoization

// Memoizar configuraciones de charts
const chartConfig = useMemo(() => {
  return curveSChartConfig(data);
}, [data]);

// Usar React.memo para componentes de charts
export const CurveSChart = React.memo<CurveSChartProps>(({ data }) => {
  // Component implementation
});

15. Testing Strategy

15.1 Unit Tests

// features/progress/components/__tests__/ProgressForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProgressForm } from '../ProgressForm';

describe('ProgressForm', () => {
  it('should validate quantity does not exceed budget', async () => {
    const onSubmit = jest.fn();
    render(<ProgressForm projectId="1" onSubmit={onSubmit} />);

    const quantityInput = screen.getByLabelText(/cantidad/i);
    fireEvent.change(quantityInput, { target: { value: '1000' } });

    await waitFor(() => {
      expect(screen.getByText(/excede la cantidad presupuestada/i)).toBeInTheDocument();
    });
  });
});

15.2 Integration Tests

// features/progress/__tests__/progressFlow.test.tsx
describe('Progress Flow', () => {
  it('should create progress entry and update curve S', async () => {
    // Test complete flow
  });
});

16. Build & Deployment

16.1 Vite Configuration

// vite.config.ts
export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'react-vendor': ['react', 'react-dom', 'react-router-dom'],
          'charts': ['chart.js', 'react-chartjs-2'],
          'maps': ['leaflet', 'react-leaflet'],
          'forms': ['react-hook-form', 'zod'],
        },
      },
    },
    chunkSizeWarningLimit: 1000,
  },
  server: {
    proxy: {
      '/api': 'http://localhost:3000',
      '/socket.io': {
        target: 'http://localhost:3000',
        ws: true,
      },
    },
  },
});

16.2 Environment Variables

# .env.example
VITE_API_URL=http://localhost:3000/api
VITE_WS_URL=http://localhost:3000
VITE_UPLOAD_MAX_SIZE=10485760
VITE_MAP_TILE_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png

17. Notas de Implementación

17.1 Prioridades

  1. Fase 1: Progress Module + CurveSChart
  2. Fase 2: WorkLog Module + PhotoGallery
  3. Fase 3: Estimations Module
  4. Fase 4: Dashboard + WebSocket real-time
  5. Fase 5: Optimizaciones y refinamiento

17.2 Consideraciones

  • Performance: Implementar virtualización en tablas largas
  • Offline: Considerar service workers para modo offline
  • Mobile: Diseño responsive con breakpoints Tailwind
  • Accessibility: Cumplir WCAG 2.1 AA
  • Security: Sanitizar uploads, validar file types
  • UX: Loading states, error boundaries, toast notifications

17.3 Dependencias con Backend

  • API endpoints definidos en ET-OBRA-002-backend.md
  • WebSocket events documentados
  • Estructura de datos alineada
  • Autenticación JWT con refresh tokens

18. Referencias


Documento: ET-OBRA-001-frontend.md Versión: 1.0.0 Última actualización: 2025-12-06