erp-construccion/docs/02-definicion-modulos/MAI-005-control-obra/especificaciones/ET-OBRA-001-frontend.md

1513 lines
37 KiB
Markdown

# 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
```typescript
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)
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```bash
# .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
- [React 18 Docs](https://react.dev)
- [Chart.js Documentation](https://www.chartjs.org/docs/)
- [Leaflet Documentation](https://leafletjs.com/reference.html)
- [Socket.io Client API](https://socket.io/docs/v4/client-api/)
- [Zustand Guide](https://docs.pmnd.rs/zustand)
---
**Documento**: ET-OBRA-001-frontend.md
**Versión**: 1.0.0
**Última actualización**: 2025-12-06