Files
mexus-app/src/components/dashboard/comparativo-dashboard.tsx
Mexus 09a29bb5c1 feat: Add estimaciones, dashboard comparativo, bulk pricing, and enhanced Gantt
- Implement complete Estimaciones module with CRUD operations
  - Create/edit/view estimaciones with partidas selection
  - Automatic calculation of accumulated amounts
  - State workflow (BORRADOR → ENVIADA → APROBADA → PAGADA)
  - Integration with presupuestos and partidas

- Add dashboard comparativo presupuesto vs ejecutado
  - Summary cards with totals and variance
  - Category breakdown table with progress
  - Per-obra comparison table with filters
  - Integrated as tab in Reportes page

- Implement bulk price update functionality
  - Support for MATERIAL, MANO_OBRA, EQUIPO types
  - Percentage or fixed value methods
  - Optional cascade recalculation of APUs
  - UI dialog in APU list

- Enhance Gantt chart with API integration
  - New /api/obras/[id]/programacion endpoint
  - Drag & drop to change task dates (persisted)
  - Progress bar drag to update completion
  - Auto-fetch complete scheduling data
  - View mode options and refresh button

- Add order creation from materials explosion
  - Material selection with checkboxes
  - Create purchase order dialog
  - Integration with existing ordenes system

- Create missing UI components (Tabs, Checkbox, Form)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 23:10:29 +00:00

404 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/hooks/use-toast";
import {
TrendingUp,
TrendingDown,
DollarSign,
Building2,
AlertTriangle,
CheckCircle2,
Loader2,
BarChart3,
} from "lucide-react";
import { CATEGORIA_GASTO_LABELS, ESTADO_OBRA_LABELS, type CategoriaGasto } from "@/types";
interface ObraComparativo {
id: string;
nombre: string;
estado: string;
porcentajeAvance: number;
presupuestado: number;
ejecutado: number;
gastado: number;
variacion: number;
variacionPorcentaje: number;
categorias: {
categoria: string;
presupuestado: number;
gastado: number;
variacion: number;
variacionPorcentaje: string;
}[];
}
interface Resumen {
totalObras: number;
totalPresupuestado: number;
totalEjecutado: number;
totalGastado: number;
variacionTotal: number;
variacionPorcentaje: number;
obrasConSobrecosto: number;
obrasBajoPresupuesto: number;
}
interface ComparativoData {
obras: ObraComparativo[];
resumen: Resumen;
}
export function ComparativoDashboard() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<ComparativoData | null>(null);
const [selectedObra, setSelectedObra] = useState<string>("all");
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const response = await fetch("/api/dashboard/comparativo");
if (!response.ok) {
throw new Error("Error al cargar datos");
}
const result = await response.json();
setData(result);
} catch (error) {
toast({
title: "Error",
description: "No se pudieron cargar los datos del dashboard",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const formatCurrency = (value: number) =>
value.toLocaleString("es-MX", {
style: "currency",
currency: "MXN",
});
const formatPercentage = (value: number) =>
`${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!data) {
return (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<AlertTriangle className="h-12 w-12 mb-4" />
<p>No hay datos disponibles</p>
</div>
);
}
const { obras, resumen } = data;
const filteredObras =
selectedObra === "all" ? obras : obras.filter((o) => o.id === selectedObra);
// Calcular resumen filtrado
const filteredResumen =
selectedObra === "all"
? resumen
: {
totalObras: 1,
totalPresupuestado: filteredObras[0]?.presupuestado || 0,
totalEjecutado: filteredObras[0]?.ejecutado || 0,
totalGastado: filteredObras[0]?.gastado || 0,
variacionTotal: filteredObras[0]?.variacion || 0,
variacionPorcentaje: filteredObras[0]?.variacionPorcentaje || 0,
obrasConSobrecosto: filteredObras[0]?.variacion < 0 ? 1 : 0,
obrasBajoPresupuesto: filteredObras[0]?.variacion > 0 ? 1 : 0,
};
// Combinar categorías de todas las obras filtradas
const categoriasCombinadas: Record<string, { presupuestado: number; gastado: number }> = {};
for (const obra of filteredObras) {
for (const cat of obra.categorias) {
if (!categoriasCombinadas[cat.categoria]) {
categoriasCombinadas[cat.categoria] = { presupuestado: 0, gastado: 0 };
}
categoriasCombinadas[cat.categoria].presupuestado += cat.presupuestado;
categoriasCombinadas[cat.categoria].gastado += cat.gastado;
}
}
return (
<div className="space-y-6">
{/* Filtro de obra */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<BarChart3 className="h-6 w-6" />
Comparativo Presupuesto vs Ejecutado
</h2>
<Select value={selectedObra} onValueChange={setSelectedObra}>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="Todas las obras" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las obras</SelectItem>
{obras.map((obra) => (
<SelectItem key={obra.id} value={obra.id}>
{obra.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Resumen Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<DollarSign className="h-4 w-4 text-blue-500" />
Total Presupuestado
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{formatCurrency(filteredResumen.totalPresupuestado)}
</p>
<p className="text-xs text-muted-foreground">
{filteredResumen.totalObras} obra(s)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<DollarSign className="h-4 w-4 text-amber-500" />
Total Gastado
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{formatCurrency(filteredResumen.totalGastado)}
</p>
<Progress
value={
filteredResumen.totalPresupuestado > 0
? (filteredResumen.totalGastado / filteredResumen.totalPresupuestado) * 100
: 0
}
className="mt-2"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{filteredResumen.variacionTotal >= 0 ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
Variación
</CardTitle>
</CardHeader>
<CardContent>
<p
className={`text-2xl font-bold ${
filteredResumen.variacionTotal >= 0 ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(filteredResumen.variacionTotal)}
</p>
<p
className={`text-sm ${
filteredResumen.variacionPorcentaje >= 0 ? "text-green-600" : "text-red-600"
}`}
>
{formatPercentage(filteredResumen.variacionPorcentaje)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Building2 className="h-4 w-4 text-slate-500" />
Estado de Obras
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-sm">{filteredResumen.obrasBajoPresupuesto} bajo</span>
</div>
<div className="flex items-center gap-1">
<AlertTriangle className="h-4 w-4 text-red-500" />
<span className="text-sm">{filteredResumen.obrasConSobrecosto} sobre</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Desglose por Categoría */}
<Card>
<CardHeader>
<CardTitle>Desglose por Categoría</CardTitle>
<CardDescription>
Comparación de presupuesto vs gasto real por categoría
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Categoría</TableHead>
<TableHead className="text-right">Presupuestado</TableHead>
<TableHead className="text-right">Gastado</TableHead>
<TableHead className="text-right">Variación</TableHead>
<TableHead className="w-[150px]">Uso</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(categoriasCombinadas)
.sort((a, b) => b[1].presupuestado - a[1].presupuestado)
.map(([categoria, datos]) => {
const variacion = datos.presupuestado - datos.gastado;
const porcentajeUso =
datos.presupuestado > 0
? (datos.gastado / datos.presupuestado) * 100
: 0;
return (
<TableRow key={categoria}>
<TableCell className="font-medium">
{CATEGORIA_GASTO_LABELS[categoria as CategoriaGasto] || categoria}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(datos.presupuestado)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(datos.gastado)}
</TableCell>
<TableCell
className={`text-right font-mono ${
variacion >= 0 ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(variacion)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress
value={Math.min(100, porcentajeUso)}
className={`h-2 ${porcentajeUso > 100 ? "bg-red-100" : ""}`}
/>
<span className="text-xs text-muted-foreground w-12">
{porcentajeUso.toFixed(0)}%
</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Tabla de Obras */}
{selectedObra === "all" && obras.length > 1 && (
<Card>
<CardHeader>
<CardTitle>Comparativo por Obra</CardTitle>
<CardDescription>
Resumen de presupuesto vs gasto para cada obra
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Obra</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="text-right">Presupuesto</TableHead>
<TableHead className="text-right">Gastado</TableHead>
<TableHead className="text-right">Variación</TableHead>
<TableHead className="w-[100px]">Avance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{obras.map((obra) => (
<TableRow key={obra.id}>
<TableCell className="font-medium">{obra.nombre}</TableCell>
<TableCell>
<Badge variant="outline">
{ESTADO_OBRA_LABELS[obra.estado as keyof typeof ESTADO_OBRA_LABELS] || obra.estado}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(obra.presupuestado)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(obra.gastado)}
</TableCell>
<TableCell
className={`text-right font-mono ${
obra.variacion >= 0 ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(obra.variacion)}
<span className="text-xs ml-1">
({obra.variacionPorcentaje >= 0 ? "+" : ""}
{obra.variacionPorcentaje.toFixed(1)}%)
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={obra.porcentajeAvance} className="h-2" />
<span className="text-xs text-muted-foreground">
{obra.porcentajeAvance.toFixed(0)}%
</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
);
}