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>
This commit is contained in:
403
src/components/dashboard/comparativo-dashboard.tsx
Normal file
403
src/components/dashboard/comparativo-dashboard.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user