- 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>
404 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|