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:
parent
e3b33f9caf
commit
5083292fbd
@ -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>
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
957
web/src/pages/admin/obras/ControlAvancePage.tsx
Normal file
957
web/src/pages/admin/obras/ControlAvancePage.tsx
Normal 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>>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><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>
|
||||
);
|
||||
}
|
||||
1528
web/src/pages/admin/obras/ProgramaObraPage.tsx
Normal file
1528
web/src/pages/admin/obras/ProgramaObraPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,4 @@
|
||||
export { AvancesObraPage } from './AvancesObraPage';
|
||||
export { BitacoraObraPage } from './BitacoraObraPage';
|
||||
export { ControlAvancePage } from './ControlAvancePage';
|
||||
export { ProgramaObraPage } from './ProgramaObraPage';
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from './avances-obra.api';
|
||||
export * from './bitacora-obra.api';
|
||||
export * from './programa-obra.api';
|
||||
|
||||
229
web/src/services/progress/programa-obra.api.ts
Normal file
229
web/src/services/progress/programa-obra.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user