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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 00:21:08 -06:00
parent e3b33f9caf
commit 5083292fbd
8 changed files with 2974 additions and 2 deletions

View File

@ -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 */}
<Route path="obras">
<Route index element={<Navigate to="avances" replace />} />
<Route index element={<Navigate to="control" replace />} />
<Route path="control" element={<ControlAvancePage />} />
<Route path="avances" element={<AvancesObraPage />} />
<Route path="programa" element={<ProgramaObraPage />} />
<Route path="bitacora" element={<BitacoraObraPage />} />
</Route>
</Route>

View File

@ -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<ApiError>) => {
@ -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,
});
}

View File

@ -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 },
],
},

View File

@ -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 (
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-500">{title}</p>
<div className="flex items-center gap-2 mt-1">
<p
className={clsx(
'text-2xl font-bold',
isWarning ? 'text-red-600' : 'text-gray-900'
)}
>
{value}
</p>
{trend && trend !== 'neutral' && (
<span
className={clsx(
'flex items-center text-sm',
trend === 'up' ? 'text-green-600' : 'text-red-600'
)}
>
{trend === 'up' ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
</span>
)}
</div>
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
</div>
<div className={clsx('p-3 rounded-lg', colorClasses[color].bg)}>
<Icon className="w-5 h-5 text-white" />
</div>
</div>
</div>
);
}
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 (
<div className="flex items-center gap-2">
<div className={clsx('flex-1 bg-gray-200 rounded-full overflow-hidden', heightClass)}>
<div
className={clsx(barColor, 'h-full rounded-full transition-all duration-300')}
style={{ width: `${percentage}%` }}
/>
</div>
{showLabel && (
<span className="text-sm font-medium text-gray-700 w-12 text-right">
{formatPercent(value, 1)}
</span>
)}
</div>
);
}
interface SimpleBarChartProps {
data: WeeklyProgress[];
height?: number;
}
function SimpleBarChart({ data, height = 200 }: SimpleBarChartProps) {
const maxValue = Math.max(...data.map((d) => d.captured), 1);
return (
<div className="relative" style={{ height }}>
<div className="absolute inset-0 flex items-end justify-around gap-2 pb-6">
{data.map((item, index) => {
const barHeight = (item.captured / maxValue) * (height - 40);
return (
<div
key={index}
className="flex flex-col items-center justify-end flex-1"
title={`Semana ${item.weekLabel}: ${item.captured.toFixed(1)}% (${item.avancesCount} avances)`}
>
<div className="text-xs text-gray-600 mb-1">
{item.captured > 0 ? `${item.captured.toFixed(0)}%` : ''}
</div>
<div
className={clsx(
'w-full rounded-t transition-all duration-300',
item.captured > 0 ? 'bg-blue-500 hover:bg-blue-600' : 'bg-gray-200'
)}
style={{ height: Math.max(barHeight, 4) }}
/>
</div>
);
})}
</div>
<div className="absolute bottom-0 left-0 right-0 flex justify-around">
{data.map((item, index) => (
<div key={index} className="text-xs text-gray-500 text-center flex-1">
{item.weekLabel}
</div>
))}
</div>
</div>
);
}
interface LotGridViewProps {
lots: Array<{
id: string;
code: string;
progress: number;
manzanaName?: string;
}>;
}
function LotGridView({ lots }: LotGridViewProps) {
if (lots.length === 0) {
return (
<div className="text-center text-gray-500 py-8">
No hay lotes con avance registrado
</div>
);
}
return (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
{lots.map((lot) => (
<div
key={lot.id}
className={clsx(
'relative p-2 rounded-lg text-center cursor-pointer transition-all hover:scale-105',
getLotProgressBgColor(lot.progress)
)}
title={`${lot.code}${lot.manzanaName ? ` - ${lot.manzanaName}` : ''}: ${formatPercent(lot.progress)}`}
>
<div
className={clsx(
'absolute inset-0 rounded-lg opacity-20',
getLotProgressColor(lot.progress)
)}
/>
<div className="relative z-10">
<div className="text-xs font-medium text-gray-800 truncate">{lot.code}</div>
<div className="text-xs font-bold text-gray-900">{Math.round(lot.progress)}%</div>
</div>
</div>
))}
</div>
);
}
// =============================================================================
// Main Component
// =============================================================================
export function ControlAvancePage() {
const [dateRange, setDateRange] = useState<DateRange>(getDefaultDateRange);
const [selectedFraccionamiento, setSelectedFraccionamiento] = useState<string>('');
const [selectedEtapa, setSelectedEtapa] = useState<string>('');
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Control de Avance</h1>
<p className="text-gray-600">
Dashboard de seguimiento y KPIs de avance de obra
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-500" />
<input
type="date"
className="px-3 py-2 text-sm border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
/>
<span className="text-gray-500">-</span>
<input
type="date"
className="px-3 py-2 text-sm border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
/>
</div>
<button
onClick={handleRefresh}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Actualizar datos"
>
<RefreshCw className={clsx('w-5 h-5', isLoading && 'animate-spin')} />
</button>
</div>
</div>
{/* Project Selector */}
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Fraccionamiento
</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={selectedFraccionamiento}
onChange={(e) => {
setSelectedFraccionamiento(e.target.value);
setSelectedEtapa('');
}}
>
<option value="">Todos los fraccionamientos</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Etapa (opcional)
</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={selectedEtapa}
onChange={(e) => setSelectedEtapa(e.target.value)}
disabled={!selectedFraccionamiento}
>
<option value="">Todas las etapas</option>
{etapas.map((etapa) => (
<option key={etapa.id} value={etapa.id}>
{etapa.name}
</option>
))}
</select>
</div>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<KPICard
title="Avance Global"
value={formatPercent(kpis.avanceGlobal)}
subtitle="Progreso real acumulado"
icon={TrendingUp}
color={kpis.avanceGlobal >= 50 ? 'green' : kpis.avanceGlobal >= 25 ? 'orange' : 'red'}
trend={kpis.variacion >= 0 ? 'up' : 'down'}
/>
<KPICard
title="Avance Planificado"
value={formatPercent(kpis.avancePlanificado)}
subtitle="Segun programa de obra"
icon={Calendar}
color="blue"
/>
<KPICard
title="Variacion"
value={`${kpis.variacion >= 0 ? '+' : ''}${formatPercent(kpis.variacion)}`}
subtitle="Real - Planificado"
icon={kpis.variacion >= 0 ? TrendingUp : TrendingDown}
color={kpis.variacion >= 0 ? 'green' : 'red'}
isWarning={kpis.variacion < -5}
/>
<KPICard
title="Dias Restantes"
value={kpis.diasRestantes}
subtitle="Para fecha fin estimada"
icon={Clock}
color={kpis.diasRestantes > 30 ? 'gray' : kpis.diasRestantes > 7 ? 'orange' : 'red'}
isWarning={kpis.diasRestantes <= 7}
/>
<KPICard
title="SPI"
value={formatNumber(kpis.spi)}
subtitle="Schedule Performance Index"
icon={BarChart3}
color={kpis.spi >= 0.95 ? 'green' : kpis.spi >= 0.85 ? 'orange' : 'red'}
isWarning={kpis.spi < 0.85}
/>
<KPICard
title="CPI"
value={formatNumber(kpis.cpi)}
subtitle="Cost Performance Index"
icon={BarChart3}
color={kpis.cpi >= 0.95 ? 'green' : kpis.cpi >= 0.85 ? 'orange' : 'red'}
isWarning={kpis.cpi < 0.85}
/>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Progress by Concept Table */}
<div className="lg:col-span-2 bg-white rounded-lg shadow-sm">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">Avance por Concepto</h2>
<p className="text-sm text-gray-500">
Desglose de avance por partida presupuestal
</p>
</div>
<div className="overflow-x-auto">
{accumulatedLoading ? (
<div className="p-8 text-center text-gray-500">Cargando conceptos...</div>
) : accumulated.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No hay datos de avance para mostrar
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Concepto
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Presupuestado
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Ejecutado
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-40">
% Avance
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
Estado
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{accumulated.slice(0, 15).map((item) => {
const status = getProgressStatus(item.porcentajeEjecutado, 50);
const statusColors = getProgressStatusColor(status);
return (
<tr key={item.conceptoId} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900">
{item.conceptoCodigo}
</div>
<div className="text-xs text-gray-500 truncate max-w-xs">
{item.conceptoNombre}
</div>
</td>
<td className="px-4 py-3 text-right text-sm text-gray-600">
{item.cantidadPresupuestada.toLocaleString()} {item.unidad}
</td>
<td className="px-4 py-3 text-right text-sm text-gray-900 font-medium">
{item.cantidadEjecutada.toLocaleString()} {item.unidad}
</td>
<td className="px-4 py-3">
<ProgressBar value={item.porcentajeEjecutado} />
</td>
<td className="px-4 py-3 text-center">
<span
className={clsx(
'inline-flex items-center px-2 py-1 text-xs font-medium rounded-full',
statusColors.bg,
statusColors.text
)}
>
{getProgressStatusLabel(status)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{accumulated.length > 15 && (
<div className="p-4 text-center border-t">
<button className="text-sm text-blue-600 hover:text-blue-800 font-medium flex items-center justify-center gap-1 mx-auto">
Ver todos los conceptos ({accumulated.length})
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
{/* Right Sidebar */}
<div className="space-y-6">
{/* Pending Approvals */}
<div className="bg-white rounded-lg shadow-sm">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">Aprobaciones Pendientes</h2>
</div>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between p-3 bg-yellow-50 rounded-lg border border-yellow-200">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-yellow-600" />
<div>
<p className="text-sm font-medium text-yellow-800">Pendientes de Revision</p>
<p className="text-xs text-yellow-600">Avances sin revisar</p>
</div>
</div>
<span className="text-2xl font-bold text-yellow-700">
{pendingCounts.pendingReview}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-blue-600" />
<div>
<p className="text-sm font-medium text-blue-800">Pendientes de Aprobacion</p>
<p className="text-xs text-blue-600">Avances revisados</p>
</div>
</div>
<span className="text-2xl font-bold text-blue-700">
{pendingCounts.pendingApproval}
</span>
</div>
{(pendingCounts.pendingReview > 0 || pendingCounts.pendingApproval > 0) && (
<a
href="/admin/obras/avances"
className="block w-full text-center px-4 py-2 text-sm text-blue-600 hover:text-blue-800 font-medium border border-blue-200 rounded-lg hover:bg-blue-50 transition-colors"
>
Ir a Cola de Aprobacion
</a>
)}
</div>
</div>
{/* Recent Activity */}
<div className="bg-white rounded-lg shadow-sm">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">Actividad Reciente</h2>
</div>
<div className="divide-y divide-gray-100 max-h-80 overflow-y-auto">
{avancesLoading ? (
<div className="p-4 text-center text-gray-500">Cargando actividad...</div>
) : recentActivity.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No hay actividad reciente
</div>
) : (
recentActivity.map((avance) => (
<div key={avance.id} className="p-3 hover:bg-gray-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-blue-600" />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{avance.concepto?.codigo || 'N/A'} - {avance.concepto?.nombre || 'Sin concepto'}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">
{formatDateShort(avance.captureDate)}
</span>
<span className="text-xs text-gray-400">|</span>
<span className="text-xs font-medium text-blue-600">
{formatPercent(avance.percentageExecuted || 0)}
</span>
</div>
{avance.capturedByName && (
<p className="text-xs text-gray-400 mt-1">
Por: {avance.capturedByName}
</p>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
{/* Bottom Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Weekly Progress Chart */}
<div className="bg-white rounded-lg shadow-sm">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">Avance Semanal</h2>
<p className="text-sm text-gray-500">Progreso capturado en las ultimas 8 semanas</p>
</div>
<div className="p-4">
<SimpleBarChart data={weeklyProgress} height={220} />
</div>
</div>
{/* Lot/Manzana Progress View */}
<div className="bg-white rounded-lg shadow-sm">
<div className="p-4 border-b flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">Avance por Lote</h2>
<p className="text-sm text-gray-500">Vista de progreso por ubicacion</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
<button
className={clsx(
'p-1.5 rounded transition-colors',
lotViewMode === 'grid'
? 'bg-white shadow-sm text-blue-600'
: 'text-gray-500 hover:text-gray-700'
)}
onClick={() => setLotViewMode('grid')}
title="Vista de cuadricula"
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
className={clsx(
'p-1.5 rounded transition-colors',
lotViewMode === 'list'
? 'bg-white shadow-sm text-blue-600'
: 'text-gray-500 hover:text-gray-700'
)}
onClick={() => setLotViewMode('list')}
title="Vista de lista"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4">
{/* Color Legend */}
<div className="flex items-center gap-4 mb-4 text-xs text-gray-600">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-green-500" />
<span>&gt;90%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-yellow-500" />
<span>60-90%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-red-500" />
<span>&lt;60%</span>
</div>
</div>
{lotViewMode === 'grid' ? (
<LotGridView lots={lotProgressData} />
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{lotProgressData.length === 0 ? (
<div className="text-center text-gray-500 py-8">
No hay lotes con avance registrado
</div>
) : (
lotProgressData.map((lot) => (
<div
key={lot.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50"
>
<div
className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center text-white text-xs font-bold',
getLotProgressColor(lot.progress)
)}
>
{Math.round(lot.progress)}%
</div>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">
Lote {lot.code}
</div>
{lot.manzanaName && (
<div className="text-xs text-gray-500">{lot.manzanaName}</div>
)}
</div>
<ProgressBar value={lot.progress} size="sm" showLabel={false} />
</div>
))
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,4 @@
export { AvancesObraPage } from './AvancesObraPage';
export { BitacoraObraPage } from './BitacoraObraPage';
export { ControlAvancePage } from './ControlAvancePage';
export { ProgramaObraPage } from './ProgramaObraPage';

View File

@ -1 +1,3 @@
export * from './avances-obra.api';
export * from './bitacora-obra.api';
export * from './programa-obra.api';

View File

@ -0,0 +1,229 @@
import api, { PaginatedResponse, PaginationParams } from '../api';
export type ProgramaObraStatus = 'activo' | 'inactivo' | 'borrador' | 'cerrado';
export interface ProgramaObra {
id: string;
tenantId: string;
fraccionamientoId: string;
fraccionamientoNombre?: string;
codigo: string;
nombre: string;
version: number;
descripcion?: string;
fechaInicio: string;
fechaFin: string;
duracionDias: number;
status: ProgramaObraStatus;
actividades?: ActividadPrograma[];
createdAt: string;
updatedAt: string;
}
export interface ActividadPrograma {
id: string;
programaId: string;
parentId?: string;
conceptoId?: string;
concepto?: {
id: string;
codigo: string;
descripcion: string;
unidad?: string;
};
wbsCode: string;
nombre: string;
fechaInicioPlaneada: string;
fechaFinPlaneada: string;
fechaInicioReal?: string;
fechaFinReal?: string;
duracionPlaneada: number;
duracionReal?: number;
pesoRelativo: number;
avancePlaneado: number;
avanceReal: number;
esCritico: boolean;
orden: number;
nivel: number;
children?: ActividadPrograma[];
createdAt: string;
updatedAt: string;
}
export interface SCurveDataPoint {
fecha: string;
avancePlaneadoAcumulado: number;
avanceRealAcumulado: number;
varianza: number;
}
export interface ProgramaObraStats {
totalProgramas: number;
programasActivos: number;
avancePromedioPlaneado: number;
avancePromedioReal: number;
varianzaPromedio: number;
}
export interface ProgramaObraFilters extends PaginationParams {
fraccionamientoId?: string;
status?: ProgramaObraStatus;
}
export interface CreateProgramaObraDto {
fraccionamientoId: string;
codigo: string;
nombre: string;
descripcion?: string;
fechaInicio: string;
fechaFin: string;
status?: ProgramaObraStatus;
}
export interface UpdateProgramaObraDto {
codigo?: string;
nombre?: string;
descripcion?: string;
fechaInicio?: string;
fechaFin?: string;
status?: ProgramaObraStatus;
}
export interface CreateActividadProgramaDto {
parentId?: string;
conceptoId?: string;
wbsCode: string;
nombre: string;
fechaInicioPlaneada: string;
fechaFinPlaneada: string;
pesoRelativo: number;
esCritico?: boolean;
orden?: number;
}
export interface UpdateActividadProgramaDto {
parentId?: string;
conceptoId?: string;
wbsCode?: string;
nombre?: string;
fechaInicioPlaneada?: string;
fechaFinPlaneada?: string;
fechaInicioReal?: string;
fechaFinReal?: string;
pesoRelativo?: number;
avanceReal?: number;
esCritico?: boolean;
orden?: number;
}
export interface ReorderActividadDto {
actividadId: string;
newOrder: number;
newParentId?: string;
}
export const programaObraApi = {
list: async (filters?: ProgramaObraFilters): Promise<PaginatedResponse<ProgramaObra>> => {
const response = await api.get<PaginatedResponse<ProgramaObra>>('/programas-obra', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<ProgramaObra> => {
const response = await api.get<ProgramaObra>(`/programas-obra/${id}`);
return response.data;
},
getVersions: async (fraccionamientoId: string): Promise<ProgramaObra[]> => {
const response = await api.get<ProgramaObra[]>('/programas-obra/versions', {
params: { fraccionamientoId },
});
return response.data;
},
getStats: async (fraccionamientoId?: string): Promise<ProgramaObraStats> => {
const response = await api.get<ProgramaObraStats>('/programas-obra/stats', {
params: fraccionamientoId ? { fraccionamientoId } : undefined,
});
return response.data;
},
getSCurve: async (id: string): Promise<SCurveDataPoint[]> => {
const response = await api.get<SCurveDataPoint[]>(`/programas-obra/${id}/s-curve`);
return response.data;
},
create: async (data: CreateProgramaObraDto): Promise<ProgramaObra> => {
const response = await api.post<ProgramaObra>('/programas-obra', data);
return response.data;
},
update: async (id: string, data: UpdateProgramaObraDto): Promise<ProgramaObra> => {
const response = await api.patch<ProgramaObra>(`/programas-obra/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/programas-obra/${id}`);
},
duplicate: async (id: string): Promise<ProgramaObra> => {
const response = await api.post<ProgramaObra>(`/programas-obra/${id}/duplicate`);
return response.data;
},
activate: async (id: string): Promise<ProgramaObra> => {
const response = await api.post<ProgramaObra>(`/programas-obra/${id}/activate`);
return response.data;
},
deactivate: async (id: string): Promise<ProgramaObra> => {
const response = await api.post<ProgramaObra>(`/programas-obra/${id}/deactivate`);
return response.data;
},
getActividades: async (programaId: string): Promise<ActividadPrograma[]> => {
const response = await api.get<ActividadPrograma[]>(`/programas-obra/${programaId}/actividades`);
return response.data;
},
addActividad: async (programaId: string, data: CreateActividadProgramaDto): Promise<ActividadPrograma> => {
const response = await api.post<ActividadPrograma>(`/programas-obra/${programaId}/actividades`, data);
return response.data;
},
updateActividad: async (
programaId: string,
actividadId: string,
data: UpdateActividadProgramaDto
): Promise<ActividadPrograma> => {
const response = await api.patch<ActividadPrograma>(
`/programas-obra/${programaId}/actividades/${actividadId}`,
data
);
return response.data;
},
deleteActividad: async (programaId: string, actividadId: string): Promise<void> => {
await api.delete(`/programas-obra/${programaId}/actividades/${actividadId}`);
},
reorderActividades: async (programaId: string, reorderData: ReorderActividadDto[]): Promise<void> => {
await api.post(`/programas-obra/${programaId}/actividades/reorder`, { items: reorderData });
},
exportPdf: async (id: string): Promise<Blob> => {
const response = await api.get(`/programas-obra/${id}/export/pdf`, {
responseType: 'blob',
});
return response.data;
},
exportExcel: async (id: string): Promise<Blob> => {
const response = await api.get(`/programas-obra/${id}/export/excel`, {
responseType: 'blob',
});
return response.data;
},
};