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:
Mexus
2026-02-05 23:10:29 +00:00
parent e964e8f0b5
commit 09a29bb5c1
29 changed files with 5771 additions and 50 deletions

View File

@@ -342,6 +342,7 @@ model Presupuesto {
// Relations // Relations
partidas PartidaPresupuesto[] partidas PartidaPresupuesto[]
estimaciones Estimacion[]
@@index([obraId]) @@index([obraId])
} }
@@ -365,6 +366,7 @@ model PartidaPresupuesto {
// Relations // Relations
gastos Gasto[] gastos Gasto[]
avances AvancePartida[] avances AvancePartida[]
estimacionPartidas EstimacionPartida[]
@@index([presupuestoId]) @@index([presupuestoId])
@@index([apuId]) @@index([apuId])
@@ -1108,6 +1110,100 @@ model ConfiguracionAPU {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// ============== ESTIMACIONES ==============
enum EstadoEstimacion {
BORRADOR
ENVIADA
EN_REVISION
APROBADA
RECHAZADA
PAGADA
}
model Estimacion {
id String @id @default(cuid())
numero Int // Número de estimación (1, 2, 3...)
periodo String // Descripción del periodo (ej: "15-31 Enero 2024")
fechaInicio DateTime // Fecha inicio del periodo
fechaFin DateTime // Fecha fin del periodo
fechaEmision DateTime @default(now())
fechaEnvio DateTime?
fechaAprobacion DateTime?
estado EstadoEstimacion @default(BORRADOR)
// Totales
importeEjecutado Float @default(0) // Monto ejecutado este periodo
importeAcumulado Float @default(0) // Monto acumulado hasta esta estimación
importeAnterior Float @default(0) // Monto de estimaciones anteriores
amortizacion Float @default(0) // Amortización de anticipo
retencion Float @default(0) // Retención (% del contrato)
porcentajeRetencion Float @default(5) // % de retención
deduccionesVarias Float @default(0) // Otras deducciones
importeNeto Float @default(0) // Importe a pagar
// IVA
subtotal Float @default(0)
iva Float @default(0)
porcentajeIVA Float @default(16)
total Float @default(0)
// Notas y observaciones
observaciones String? @db.Text
motivoRechazo String?
// Relaciones
presupuestoId String
presupuesto Presupuesto @relation(fields: [presupuestoId], references: [id], onDelete: Cascade)
partidas EstimacionPartida[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([presupuestoId, numero])
@@index([presupuestoId])
@@index([estado])
@@index([fechaEmision])
}
model EstimacionPartida {
id String @id @default(cuid())
// Cantidades
cantidadContrato Float // Cantidad original del presupuesto
cantidadAnterior Float @default(0) // Ejecutado en estimaciones previas
cantidadEstimacion Float // Ejecutado en esta estimación
cantidadAcumulada Float // Anterior + Esta estimación
cantidadPendiente Float // Contrato - Acumulada
// Importes
precioUnitario Float
importeAnterior Float @default(0)
importeEstimacion Float // cantidadEstimacion * precioUnitario
importeAcumulado Float
// Porcentajes
porcentajeAnterior Float @default(0)
porcentajeEstimacion Float
porcentajeAcumulado Float
// Notas para esta partida
notas String?
// Relaciones
estimacionId String
estimacion Estimacion @relation(fields: [estimacionId], references: [id], onDelete: Cascade)
partidaId String
partida PartidaPresupuesto @relation(fields: [partidaId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([estimacionId, partidaId])
@@index([estimacionId])
@@index([partidaId])
}
// ============== IMPORTACIÓN DE CATÁLOGOS ============== // ============== IMPORTACIÓN DE CATÁLOGOS ==============
enum TipoImportacion { enum TipoImportacion {

View File

@@ -0,0 +1,104 @@
import { redirect, notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { EstimacionForm } from "@/components/estimaciones";
interface Props {
params: Promise<{ id: string; estimacionId: string }>;
}
export default async function EditarEstimacionPage({ params }: Props) {
const session = await auth();
if (!session?.user?.empresaId) {
redirect("/login");
}
const { id: obraId, estimacionId } = await params;
// Obtener la estimación existente
const estimacion = await prisma.estimacion.findFirst({
where: {
id: estimacionId,
estado: "BORRADOR", // Solo se puede editar en borrador
presupuesto: {
obra: {
id: obraId,
empresaId: session.user.empresaId,
},
},
},
include: {
presupuesto: {
include: {
obra: {
select: {
id: true,
nombre: true,
},
},
partidas: {
orderBy: { codigo: "asc" },
},
},
},
partidas: {
select: {
partidaId: true,
cantidadEstimacion: true,
notas: true,
},
},
},
});
if (!estimacion) {
notFound();
}
// Obtener acumulados de estimaciones anteriores (excluyendo la actual)
const estimacionesAnteriores = await prisma.estimacionPartida.findMany({
where: {
estimacion: {
presupuestoId: estimacion.presupuestoId,
id: { not: estimacionId },
estado: { not: "RECHAZADA" },
},
},
select: {
partidaId: true,
cantidadEstimacion: true,
importeEstimacion: true,
},
});
const acumuladosAnteriores: Record<string, { cantidad: number; importe: number }> = {};
for (const ep of estimacionesAnteriores) {
if (!acumuladosAnteriores[ep.partidaId]) {
acumuladosAnteriores[ep.partidaId] = { cantidad: 0, importe: 0 };
}
acumuladosAnteriores[ep.partidaId].cantidad += ep.cantidadEstimacion;
acumuladosAnteriores[ep.partidaId].importe += ep.importeEstimacion;
}
return (
<div className="container mx-auto py-6">
<EstimacionForm
presupuesto={estimacion.presupuesto}
acumuladosAnteriores={acumuladosAnteriores}
estimacionExistente={{
id: estimacion.id,
numero: estimacion.numero,
periodo: estimacion.periodo,
fechaInicio: estimacion.fechaInicio,
fechaFin: estimacion.fechaFin,
porcentajeRetencion: estimacion.porcentajeRetencion,
porcentajeIVA: estimacion.porcentajeIVA,
amortizacion: estimacion.amortizacion,
deduccionesVarias: estimacion.deduccionesVarias,
observaciones: estimacion.observaciones,
partidas: estimacion.partidas,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { redirect, notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { EstimacionDetail } from "@/components/estimaciones";
interface Props {
params: Promise<{ id: string; estimacionId: string }>;
}
export default async function EstimacionDetailPage({ params }: Props) {
const session = await auth();
if (!session?.user?.empresaId) {
redirect("/login");
}
const { id: obraId, estimacionId } = await params;
const estimacion = await prisma.estimacion.findFirst({
where: {
id: estimacionId,
presupuesto: {
obra: {
id: obraId,
empresaId: session.user.empresaId,
},
},
},
include: {
presupuesto: {
select: {
id: true,
nombre: true,
total: true,
obra: {
select: {
id: true,
nombre: true,
cliente: {
select: {
nombre: true,
},
},
},
},
},
},
partidas: {
include: {
partida: {
select: {
id: true,
codigo: true,
descripcion: true,
unidad: true,
},
},
},
orderBy: {
partida: {
codigo: "asc",
},
},
},
},
});
if (!estimacion) {
notFound();
}
return (
<div className="container mx-auto py-6">
<EstimacionDetail estimacion={estimacion} />
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { EstimacionForm } from "@/components/estimaciones";
interface Props {
params: Promise<{ id: string }>;
searchParams: Promise<{ presupuestoId?: string }>;
}
export default async function NuevaEstimacionPage({ params, searchParams }: Props) {
const session = await auth();
if (!session?.user?.empresaId) {
redirect("/login");
}
const { id: obraId } = await params;
const { presupuestoId } = await searchParams;
if (!presupuestoId) {
redirect(`/obras/${obraId}`);
}
// Obtener presupuesto con partidas
const presupuesto = await prisma.presupuesto.findFirst({
where: {
id: presupuestoId,
obra: {
id: obraId,
empresaId: session.user.empresaId,
},
},
include: {
obra: {
select: {
id: true,
nombre: true,
},
},
partidas: {
orderBy: { codigo: "asc" },
},
},
});
if (!presupuesto) {
redirect(`/obras/${obraId}`);
}
// Obtener acumulados de estimaciones anteriores
const estimacionesAnteriores = await prisma.estimacionPartida.findMany({
where: {
estimacion: {
presupuestoId,
estado: { not: "RECHAZADA" },
},
},
select: {
partidaId: true,
cantidadEstimacion: true,
importeEstimacion: true,
},
});
const acumuladosAnteriores: Record<string, { cantidad: number; importe: number }> = {};
for (const ep of estimacionesAnteriores) {
if (!acumuladosAnteriores[ep.partidaId]) {
acumuladosAnteriores[ep.partidaId] = { cantidad: 0, importe: 0 };
}
acumuladosAnteriores[ep.partidaId].cantidad += ep.cantidadEstimacion;
acumuladosAnteriores[ep.partidaId].importe += ep.importeEstimacion;
}
return (
<div className="container mx-auto py-6">
<EstimacionForm
presupuesto={presupuesto}
acumuladosAnteriores={acumuladosAnteriores}
/>
</div>
);
}

View File

@@ -54,6 +54,7 @@ import {
} from "@/components/pdf"; } from "@/components/pdf";
import { PartidasManager, ExplosionInsumos } from "@/components/presupuesto"; import { PartidasManager, ExplosionInsumos } from "@/components/presupuesto";
import { ControlAvancePartidas } from "@/components/avances"; import { ControlAvancePartidas } from "@/components/avances";
import { EstimacionesList } from "@/components/estimaciones";
// Componente de carga // Componente de carga
const LoadingSpinner = () => ( const LoadingSpinner = () => (
@@ -143,6 +144,17 @@ interface ObraDetailProps {
precioUnitario: number; precioUnitario: number;
} | null; } | null;
}[]; }[];
estimaciones: {
id: string;
numero: number;
periodo: string;
fechaEmision: Date;
estado: import("@prisma/client").EstadoEstimacion;
importeEjecutado: number;
importeAcumulado: number;
importeNeto: number;
_count: { partidas: number };
}[];
}[]; }[];
gastos: { gastos: {
id: string; id: string;
@@ -586,6 +598,7 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
<ExplosionInsumos <ExplosionInsumos
presupuestoId={presupuesto.id} presupuestoId={presupuesto.id}
presupuestoNombre={presupuesto.nombre} presupuestoNombre={presupuesto.nombre}
obraId={obra.id}
/> />
<div className="text-right"> <div className="text-right">
<p className="text-xl font-bold"> <p className="text-xl font-bold">
@@ -605,6 +618,7 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
<TabsList className="mb-4"> <TabsList className="mb-4">
<TabsTrigger value="partidas">Partidas</TabsTrigger> <TabsTrigger value="partidas">Partidas</TabsTrigger>
<TabsTrigger value="avances">Control de Avance</TabsTrigger> <TabsTrigger value="avances">Control de Avance</TabsTrigger>
<TabsTrigger value="estimaciones">Estimaciones</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="partidas"> <TabsContent value="partidas">
<PartidasManager <PartidasManager
@@ -621,6 +635,21 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
presupuestoNombre={presupuesto.nombre} presupuestoNombre={presupuesto.nombre}
/> />
</TabsContent> </TabsContent>
<TabsContent value="estimaciones">
<EstimacionesList
estimaciones={presupuesto.estimaciones.map((e) => ({
...e,
fechaEmision: e.fechaEmision.toString(),
presupuesto: {
id: presupuesto.id,
nombre: presupuesto.nombre,
obra: { id: obra.id, nombre: obra.nombre },
},
}))}
presupuestoId={presupuesto.id}
obraId={obra.id}
/>
</TabsContent>
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -37,6 +37,20 @@ async function getObra(id: string, empresaId: string) {
}, },
orderBy: { codigo: "asc" }, orderBy: { codigo: "asc" },
}, },
estimaciones: {
select: {
id: true,
numero: true,
periodo: true,
fechaEmision: true,
estado: true,
importeEjecutado: true,
importeAcumulado: true,
importeNeto: true,
_count: { select: { partidas: true } },
},
orderBy: { numero: "desc" },
},
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}, },

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { useState } from "react";
import { pdf } from "@react-pdf/renderer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -15,7 +17,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Download, FileSpreadsheet, FileText } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FileSpreadsheet, FileText, Loader2, BarChart3, TrendingUp } from "lucide-react";
import { ComparativoDashboard } from "@/components/dashboard";
import { formatCurrency, formatPercentage } from "@/lib/utils"; import { formatCurrency, formatPercentage } from "@/lib/utils";
import { import {
CATEGORIA_GASTO_LABELS, CATEGORIA_GASTO_LABELS,
@@ -38,7 +42,208 @@ import {
LineChart, LineChart,
Line, Line,
} from "recharts"; } from "recharts";
import { useState } from "react"; import {
Document,
Page,
Text,
View,
StyleSheet,
} from "@react-pdf/renderer";
import { toast } from "@/hooks/use-toast";
// Estilos para el PDF
const pdfStyles = StyleSheet.create({
page: {
padding: 30,
fontSize: 10,
fontFamily: "Helvetica",
},
header: {
marginBottom: 20,
borderBottom: "1 solid #333",
paddingBottom: 10,
},
title: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 5,
},
subtitle: {
fontSize: 12,
color: "#666",
},
section: {
marginBottom: 15,
},
sectionTitle: {
fontSize: 12,
fontWeight: "bold",
marginBottom: 8,
backgroundColor: "#f0f0f0",
padding: 5,
},
table: {
width: "100%",
},
tableHeader: {
flexDirection: "row",
backgroundColor: "#e0e0e0",
borderBottom: "1 solid #333",
padding: 5,
fontWeight: "bold",
},
tableRow: {
flexDirection: "row",
borderBottom: "1 solid #ddd",
padding: 5,
},
col1: { width: "25%" },
col2: { width: "15%" },
col3: { width: "20%", textAlign: "right" },
col4: { width: "20%", textAlign: "right" },
col5: { width: "20%", textAlign: "right" },
summaryBox: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 10,
},
summaryItem: {
padding: 10,
backgroundColor: "#f5f5f5",
width: "23%",
borderRadius: 4,
},
summaryLabel: {
fontSize: 8,
color: "#666",
marginBottom: 3,
},
summaryValue: {
fontSize: 12,
fontWeight: "bold",
},
footer: {
position: "absolute",
bottom: 30,
left: 30,
right: 30,
textAlign: "center",
fontSize: 8,
color: "#999",
},
positive: {
color: "#16a34a",
},
negative: {
color: "#dc2626",
},
});
// Componente PDF del Reporte
const ReportePDF = ({ data, totalPresupuesto, totalGastado, variacion }: {
data: ReportesData;
totalPresupuesto: number;
totalGastado: number;
variacion: number;
}) => (
<Document>
<Page size="A4" style={pdfStyles.page}>
{/* Header */}
<View style={pdfStyles.header}>
<Text style={pdfStyles.title}>Reporte General de Obras</Text>
<Text style={pdfStyles.subtitle}>
Generado el {new Date().toLocaleDateString("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
})}
</Text>
</View>
{/* Resumen */}
<View style={pdfStyles.section}>
<Text style={pdfStyles.sectionTitle}>Resumen Ejecutivo</Text>
<View style={pdfStyles.summaryBox}>
<View style={pdfStyles.summaryItem}>
<Text style={pdfStyles.summaryLabel}>Presupuesto Total</Text>
<Text style={pdfStyles.summaryValue}>
${totalPresupuesto.toLocaleString("es-MX", { minimumFractionDigits: 2 })}
</Text>
</View>
<View style={pdfStyles.summaryItem}>
<Text style={pdfStyles.summaryLabel}>Total Gastado</Text>
<Text style={pdfStyles.summaryValue}>
${totalGastado.toLocaleString("es-MX", { minimumFractionDigits: 2 })}
</Text>
</View>
<View style={pdfStyles.summaryItem}>
<Text style={pdfStyles.summaryLabel}>Variacion</Text>
<Text style={[pdfStyles.summaryValue, variacion >= 0 ? pdfStyles.positive : pdfStyles.negative]}>
{variacion >= 0 ? "+" : ""}${variacion.toLocaleString("es-MX", { minimumFractionDigits: 2 })}
</Text>
</View>
<View style={pdfStyles.summaryItem}>
<Text style={pdfStyles.summaryLabel}>% Ejecutado</Text>
<Text style={pdfStyles.summaryValue}>
{totalPresupuesto > 0 ? ((totalGastado / totalPresupuesto) * 100).toFixed(1) : "0"}%
</Text>
</View>
</View>
</View>
{/* Tabla de Obras */}
<View style={pdfStyles.section}>
<Text style={pdfStyles.sectionTitle}>Detalle por Obra</Text>
<View style={pdfStyles.table}>
<View style={pdfStyles.tableHeader}>
<Text style={pdfStyles.col1}>Obra</Text>
<Text style={pdfStyles.col2}>Estado</Text>
<Text style={pdfStyles.col3}>Presupuesto</Text>
<Text style={pdfStyles.col4}>Gastado</Text>
<Text style={pdfStyles.col5}>Avance</Text>
</View>
{data.obras.map((obra) => (
<View key={obra.id} style={pdfStyles.tableRow}>
<Text style={pdfStyles.col1}>{obra.nombre}</Text>
<Text style={pdfStyles.col2}>{ESTADO_OBRA_LABELS[obra.estado]}</Text>
<Text style={pdfStyles.col3}>
${obra.presupuestoTotal.toLocaleString("es-MX")}
</Text>
<Text style={pdfStyles.col4}>
${obra.gastoTotal.toLocaleString("es-MX")}
</Text>
<Text style={pdfStyles.col5}>{obra.porcentajeAvance}%</Text>
</View>
))}
</View>
</View>
{/* Gastos por Categoria */}
<View style={pdfStyles.section}>
<Text style={pdfStyles.sectionTitle}>Gastos por Categoria</Text>
<View style={pdfStyles.table}>
<View style={pdfStyles.tableHeader}>
<Text style={{ width: "60%" }}>Categoria</Text>
<Text style={{ width: "40%", textAlign: "right" }}>Total</Text>
</View>
{data.gastosPorCategoria.map((g, index) => (
<View key={index} style={pdfStyles.tableRow}>
<Text style={{ width: "60%" }}>{CATEGORIA_GASTO_LABELS[g.categoria]}</Text>
<Text style={{ width: "40%", textAlign: "right" }}>
${g.total.toLocaleString("es-MX", { minimumFractionDigits: 2 })}
</Text>
</View>
))}
</View>
</View>
{/* Footer */}
<Text style={pdfStyles.footer}>
Sistema de Gestion de Obras - Reporte generado automaticamente
</Text>
</Page>
</Document>
);
interface ReportesData { interface ReportesData {
obras: { obras: {
@@ -79,6 +284,7 @@ const COLORS = [
export function ReportesClient({ data }: { data: ReportesData }) { export function ReportesClient({ data }: { data: ReportesData }) {
const [selectedObra, setSelectedObra] = useState<string>("all"); const [selectedObra, setSelectedObra] = useState<string>("all");
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const totalPresupuesto = data.obras.reduce( const totalPresupuesto = data.obras.reduce(
(sum, o) => sum + o.presupuestoTotal, (sum, o) => sum + o.presupuestoTotal,
@@ -116,6 +322,41 @@ export function ReportesClient({ data }: { data: ReportesData }) {
link.click(); link.click();
}; };
const exportToPDF = async () => {
setIsGeneratingPDF(true);
try {
const pdfBlob = await pdf(
<ReportePDF
data={data}
totalPresupuesto={totalPresupuesto}
totalGastado={totalGastado}
variacion={variacion}
/>
).toBlob();
const url = URL.createObjectURL(pdfBlob);
const link = document.createElement("a");
link.href = url;
link.download = `reporte-obras-${new Date().toISOString().split("T")[0]}.pdf`;
link.click();
URL.revokeObjectURL(url);
toast({
title: "PDF generado",
description: "El reporte se ha descargado correctamente",
});
} catch (error) {
console.error("Error generating PDF:", error);
toast({
title: "Error",
description: "No se pudo generar el PDF",
variant: "destructive",
});
} finally {
setIsGeneratingPDF(false);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -125,17 +366,36 @@ export function ReportesClient({ data }: { data: ReportesData }) {
Analisis y exportacion de datos Analisis y exportacion de datos
</p> </p>
</div> </div>
<div className="flex gap-2"> </div>
<Tabs defaultValue="general" className="space-y-6">
<TabsList>
<TabsTrigger value="general" className="gap-2">
<BarChart3 className="h-4 w-4" />
Reporte General
</TabsTrigger>
<TabsTrigger value="comparativo" className="gap-2">
<TrendingUp className="h-4 w-4" />
Presupuesto vs Ejecutado
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
{/* Export buttons for general reports */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={exportToCSV}> <Button variant="outline" onClick={exportToCSV}>
<FileSpreadsheet className="mr-2 h-4 w-4" /> <FileSpreadsheet className="mr-2 h-4 w-4" />
Exportar CSV Exportar CSV
</Button> </Button>
<Button variant="outline"> <Button variant="outline" onClick={exportToPDF} disabled={isGeneratingPDF}>
{isGeneratingPDF ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Exportar PDF )}
{isGeneratingPDF ? "Generando..." : "Exportar PDF"}
</Button> </Button>
</div> </div>
</div>
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
@@ -361,6 +621,12 @@ export function ReportesClient({ data }: { data: ReportesData }) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
<TabsContent value="comparativo">
<ComparativoDashboard />
</TabsContent>
</Tabs>
</div> </div>
); );
} }

View File

@@ -0,0 +1,203 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
// GET - Obtener datos comparativos de presupuesto vs ejecutado
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
const empresaId = session.user.empresaId;
// Obtener obras con datos de presupuesto y gastos
const whereClause: Record<string, unknown> = { empresaId };
if (obraId) {
whereClause.id = obraId;
}
const obras = await prisma.obra.findMany({
where: whereClause,
select: {
id: true,
nombre: true,
estado: true,
presupuestoTotal: true,
gastoTotal: true,
porcentajeAvance: true,
presupuestos: {
where: { aprobado: true },
select: {
id: true,
nombre: true,
total: true,
partidas: {
select: {
id: true,
codigo: true,
descripcion: true,
categoria: true,
cantidad: true,
precioUnitario: true,
total: true,
avances: {
select: {
montoAcumulado: true,
},
orderBy: { fecha: "desc" },
take: 1,
},
},
},
estimaciones: {
where: {
estado: { in: ["APROBADA", "PAGADA"] },
},
select: {
importeEjecutado: true,
importeAcumulado: true,
},
orderBy: { numero: "desc" },
take: 1,
},
},
},
gastos: {
where: { estado: "APROBADO" },
select: {
categoria: true,
monto: true,
},
},
},
});
// Procesar datos para cada obra
const comparativo = obras.map((obra) => {
// Total presupuestado de presupuestos aprobados
const totalPresupuestado = obra.presupuestos.reduce(
(sum, p) => sum + p.total,
0
);
// Total ejecutado basado en estimaciones aprobadas
let totalEjecutadoEstimaciones = 0;
for (const presupuesto of obra.presupuestos) {
if (presupuesto.estimaciones.length > 0) {
totalEjecutadoEstimaciones +=
presupuesto.estimaciones[0].importeAcumulado;
}
}
// Total ejecutado basado en avances de partidas
let totalEjecutadoAvances = 0;
for (const presupuesto of obra.presupuestos) {
for (const partida of presupuesto.partidas) {
if (partida.avances.length > 0) {
totalEjecutadoAvances += partida.avances[0].montoAcumulado;
}
}
}
// Usar el mayor entre estimaciones y avances
const totalEjecutado = Math.max(
totalEjecutadoEstimaciones,
totalEjecutadoAvances
);
// Total gastado según gastos aprobados
const totalGastado = obra.gastos.reduce((sum, g) => sum + g.monto, 0);
// Variación
const variacion = totalPresupuestado - totalGastado;
const variacionPorcentaje =
totalPresupuestado > 0
? ((variacion / totalPresupuestado) * 100).toFixed(1)
: "0";
// Desglose por categoría
const categorias: Record<
string,
{ presupuestado: number; gastado: number }
> = {};
// Presupuestado por categoría
for (const presupuesto of obra.presupuestos) {
for (const partida of presupuesto.partidas) {
const cat = partida.categoria;
if (!categorias[cat]) {
categorias[cat] = { presupuestado: 0, gastado: 0 };
}
categorias[cat].presupuestado += partida.total;
}
}
// Gastado por categoría
for (const gasto of obra.gastos) {
const cat = gasto.categoria;
if (!categorias[cat]) {
categorias[cat] = { presupuestado: 0, gastado: 0 };
}
categorias[cat].gastado += gasto.monto;
}
return {
id: obra.id,
nombre: obra.nombre,
estado: obra.estado,
porcentajeAvance: obra.porcentajeAvance,
presupuestado: totalPresupuestado || obra.presupuestoTotal,
ejecutado: totalEjecutado,
gastado: totalGastado || obra.gastoTotal,
variacion,
variacionPorcentaje: parseFloat(variacionPorcentaje),
categorias: Object.entries(categorias).map(([categoria, datos]) => ({
categoria,
presupuestado: datos.presupuestado,
gastado: datos.gastado,
variacion: datos.presupuestado - datos.gastado,
variacionPorcentaje:
datos.presupuestado > 0
? (
((datos.presupuestado - datos.gastado) / datos.presupuestado) *
100
).toFixed(1)
: "0",
})),
};
});
// Resumen global
const resumen = {
totalObras: obras.length,
totalPresupuestado: comparativo.reduce((sum, o) => sum + o.presupuestado, 0),
totalEjecutado: comparativo.reduce((sum, o) => sum + o.ejecutado, 0),
totalGastado: comparativo.reduce((sum, o) => sum + o.gastado, 0),
variacionTotal: 0,
variacionPorcentaje: 0,
obrasConSobrecosto: comparativo.filter((o) => o.variacion < 0).length,
obrasBajoPresupuesto: comparativo.filter((o) => o.variacion > 0).length,
};
resumen.variacionTotal = resumen.totalPresupuestado - resumen.totalGastado;
resumen.variacionPorcentaje =
resumen.totalPresupuestado > 0
? (resumen.variacionTotal / resumen.totalPresupuestado) * 100
: 0;
return NextResponse.json({
obras: comparativo,
resumen,
});
} catch (error) {
console.error("Error al obtener datos comparativos:", error);
return NextResponse.json(
{ error: "Error al obtener datos comparativos" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,438 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import {
actualizarEstimacionSchema,
cambiarEstadoEstimacionSchema,
calcularTotalesEstimacion
} from "@/lib/validations/estimaciones";
// GET - Obtener estimación por ID
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const estimacion = await prisma.estimacion.findFirst({
where: {
id,
presupuesto: {
obra: {
empresaId: session.user.empresaId,
},
},
},
include: {
presupuesto: {
select: {
id: true,
nombre: true,
total: true,
obra: {
select: {
id: true,
nombre: true,
cliente: {
select: {
nombre: true,
},
},
},
},
},
},
partidas: {
include: {
partida: {
select: {
id: true,
codigo: true,
descripcion: true,
unidad: true,
cantidad: true,
precioUnitario: true,
total: true,
},
},
},
orderBy: {
partida: {
codigo: "asc",
},
},
},
},
});
if (!estimacion) {
return NextResponse.json(
{ error: "Estimación no encontrada" },
{ status: 404 }
);
}
return NextResponse.json(estimacion);
} catch (error) {
console.error("Error al obtener estimación:", error);
return NextResponse.json(
{ error: "Error al obtener estimación" },
{ status: 500 }
);
}
}
// PUT - Actualizar estimación
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
// Verificar si es actualización de estado
const estadoValidation = cambiarEstadoEstimacionSchema.safeParse(body);
if (estadoValidation.success) {
return await actualizarEstado(id, session.user.empresaId, estadoValidation.data);
}
// Es actualización de datos
const validation = actualizarEstimacionSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Datos inválidos", details: validation.error.errors },
{ status: 400 }
);
}
const data = validation.data;
// Verificar que la estimación existe y pertenece a la empresa
const estimacionExistente = await prisma.estimacion.findFirst({
where: {
id,
presupuesto: {
obra: {
empresaId: session.user.empresaId,
},
},
},
include: {
presupuesto: {
include: {
partidas: true,
},
},
},
});
if (!estimacionExistente) {
return NextResponse.json(
{ error: "Estimación no encontrada" },
{ status: 404 }
);
}
// Solo se puede editar si está en borrador
if (estimacionExistente.estado !== "BORRADOR") {
return NextResponse.json(
{ error: "Solo se pueden editar estimaciones en borrador" },
{ status: 400 }
);
}
// Si se actualizan partidas, recalcular todo
if (data.partidas) {
// Obtener acumulados anteriores
const estimacionesAnteriores = await prisma.estimacionPartida.findMany({
where: {
estimacion: {
presupuestoId: estimacionExistente.presupuestoId,
id: { not: id },
estado: { not: "RECHAZADA" },
},
},
select: {
partidaId: true,
cantidadEstimacion: true,
importeEstimacion: true,
},
});
const acumuladosPorPartida: Record<string, { cantidad: number; importe: number }> = {};
for (const ep of estimacionesAnteriores) {
if (!acumuladosPorPartida[ep.partidaId]) {
acumuladosPorPartida[ep.partidaId] = { cantidad: 0, importe: 0 };
}
acumuladosPorPartida[ep.partidaId].cantidad += ep.cantidadEstimacion;
acumuladosPorPartida[ep.partidaId].importe += ep.importeEstimacion;
}
// Preparar partidas para cálculo
const partidasParaCalculo = data.partidas.map((p) => {
const partidaPresupuesto = estimacionExistente.presupuesto.partidas.find(
(pp) => pp.id === p.partidaId
);
if (!partidaPresupuesto) {
throw new Error(`Partida ${p.partidaId} no encontrada`);
}
const acumulado = acumuladosPorPartida[p.partidaId] || { cantidad: 0, importe: 0 };
return {
cantidadEstimacion: p.cantidadEstimacion,
precioUnitario: partidaPresupuesto.precioUnitario,
cantidadAnterior: acumulado.cantidad,
cantidadContrato: partidaPresupuesto.cantidad,
};
});
const totales = calcularTotalesEstimacion(partidasParaCalculo, {
porcentajeRetencion: data.porcentajeRetencion ?? estimacionExistente.porcentajeRetencion,
porcentajeIVA: data.porcentajeIVA ?? estimacionExistente.porcentajeIVA,
amortizacion: data.amortizacion ?? estimacionExistente.amortizacion,
deduccionesVarias: data.deduccionesVarias ?? estimacionExistente.deduccionesVarias,
});
// Eliminar partidas existentes y crear nuevas
await prisma.estimacionPartida.deleteMany({
where: { estimacionId: id },
});
const estimacion = await prisma.estimacion.update({
where: { id },
data: {
periodo: data.periodo,
fechaInicio: data.fechaInicio ? new Date(data.fechaInicio) : undefined,
fechaFin: data.fechaFin ? new Date(data.fechaFin) : undefined,
porcentajeRetencion: data.porcentajeRetencion,
porcentajeIVA: data.porcentajeIVA,
amortizacion: data.amortizacion,
deduccionesVarias: data.deduccionesVarias,
observaciones: data.observaciones,
importeEjecutado: totales.importeEjecutado,
importeAnterior: totales.importeAnterior,
importeAcumulado: totales.importeAcumulado,
retencion: totales.retencion,
subtotal: totales.subtotal,
iva: totales.iva,
total: totales.total,
importeNeto: totales.importeNeto,
partidas: {
create: data.partidas.map((p) => {
const partidaPresupuesto = estimacionExistente.presupuesto.partidas.find(
(pp) => pp.id === p.partidaId
)!;
const acumulado = acumuladosPorPartida[p.partidaId] || { cantidad: 0, importe: 0 };
const cantidadAcumulada = acumulado.cantidad + p.cantidadEstimacion;
const importeEstimacion = p.cantidadEstimacion * partidaPresupuesto.precioUnitario;
const importeAcumulado = acumulado.importe + importeEstimacion;
const porcentajeAnterior = (acumulado.cantidad / partidaPresupuesto.cantidad) * 100;
const porcentajeEstimacion = (p.cantidadEstimacion / partidaPresupuesto.cantidad) * 100;
const porcentajeAcumulado = (cantidadAcumulada / partidaPresupuesto.cantidad) * 100;
return {
partidaId: p.partidaId,
cantidadContrato: partidaPresupuesto.cantidad,
cantidadAnterior: acumulado.cantidad,
cantidadEstimacion: p.cantidadEstimacion,
cantidadAcumulada,
cantidadPendiente: partidaPresupuesto.cantidad - cantidadAcumulada,
precioUnitario: partidaPresupuesto.precioUnitario,
importeAnterior: acumulado.importe,
importeEstimacion,
importeAcumulado,
porcentajeAnterior,
porcentajeEstimacion,
porcentajeAcumulado,
notas: p.notas,
};
}),
},
},
include: {
partidas: {
include: {
partida: {
select: {
codigo: true,
descripcion: true,
unidad: true,
},
},
},
},
},
});
return NextResponse.json(estimacion);
}
// Actualización simple sin partidas
const estimacion = await prisma.estimacion.update({
where: { id },
data: {
periodo: data.periodo,
fechaInicio: data.fechaInicio ? new Date(data.fechaInicio) : undefined,
fechaFin: data.fechaFin ? new Date(data.fechaFin) : undefined,
porcentajeRetencion: data.porcentajeRetencion,
porcentajeIVA: data.porcentajeIVA,
amortizacion: data.amortizacion,
deduccionesVarias: data.deduccionesVarias,
observaciones: data.observaciones,
},
});
return NextResponse.json(estimacion);
} catch (error) {
console.error("Error al actualizar estimación:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Error al actualizar estimación" },
{ status: 500 }
);
}
}
// Helper para actualizar estado
async function actualizarEstado(
id: string,
empresaId: string,
data: { estado: string; motivoRechazo?: string }
) {
const estimacion = await prisma.estimacion.findFirst({
where: {
id,
presupuesto: {
obra: {
empresaId,
},
},
},
});
if (!estimacion) {
return NextResponse.json(
{ error: "Estimación no encontrada" },
{ status: 404 }
);
}
// Validar transiciones de estado válidas
const transicionesValidas: Record<string, string[]> = {
BORRADOR: ["ENVIADA"],
ENVIADA: ["EN_REVISION", "RECHAZADA"],
EN_REVISION: ["APROBADA", "RECHAZADA"],
APROBADA: ["PAGADA"],
RECHAZADA: ["BORRADOR"],
PAGADA: [],
};
if (!transicionesValidas[estimacion.estado]?.includes(data.estado)) {
return NextResponse.json(
{
error: `No se puede cambiar de ${estimacion.estado} a ${data.estado}`,
},
{ status: 400 }
);
}
const updateData: Record<string, unknown> = {
estado: data.estado,
};
// Agregar fechas según el estado
if (data.estado === "ENVIADA") {
updateData.fechaEnvio = new Date();
} else if (data.estado === "APROBADA") {
updateData.fechaAprobacion = new Date();
} else if (data.estado === "RECHAZADA") {
updateData.motivoRechazo = data.motivoRechazo || "Sin motivo especificado";
}
const estimacionActualizada = await prisma.estimacion.update({
where: { id },
data: updateData,
include: {
presupuesto: {
select: {
nombre: true,
obra: {
select: {
nombre: true,
},
},
},
},
},
});
return NextResponse.json(estimacionActualizada);
}
// DELETE - Eliminar estimación
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
// Verificar que la estimación existe
const estimacion = await prisma.estimacion.findFirst({
where: {
id,
presupuesto: {
obra: {
empresaId: session.user.empresaId,
},
},
},
});
if (!estimacion) {
return NextResponse.json(
{ error: "Estimación no encontrada" },
{ status: 404 }
);
}
// Solo se puede eliminar si está en borrador o rechazada
if (!["BORRADOR", "RECHAZADA"].includes(estimacion.estado)) {
return NextResponse.json(
{ error: "Solo se pueden eliminar estimaciones en borrador o rechazadas" },
{ status: 400 }
);
}
await prisma.estimacion.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error al eliminar estimación:", error);
return NextResponse.json(
{ error: "Error al eliminar estimación" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,263 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { crearEstimacionSchema, calcularTotalesEstimacion } from "@/lib/validations/estimaciones";
// GET - Listar estimaciones (por presupuesto)
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const presupuestoId = searchParams.get("presupuestoId");
const whereClause: Record<string, unknown> = {
presupuesto: {
obra: {
empresaId: session.user.empresaId,
},
},
};
if (presupuestoId) {
whereClause.presupuestoId = presupuestoId;
}
const estimaciones = await prisma.estimacion.findMany({
where: whereClause,
include: {
presupuesto: {
select: {
id: true,
nombre: true,
obra: {
select: {
id: true,
nombre: true,
},
},
},
},
partidas: {
include: {
partida: {
select: {
codigo: true,
descripcion: true,
unidad: true,
},
},
},
},
_count: {
select: {
partidas: true,
},
},
},
orderBy: [{ presupuestoId: "asc" }, { numero: "desc" }],
});
return NextResponse.json(estimaciones);
} catch (error) {
console.error("Error al obtener estimaciones:", error);
return NextResponse.json(
{ error: "Error al obtener estimaciones" },
{ status: 500 }
);
}
}
// POST - Crear nueva estimación
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validation = crearEstimacionSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Datos inválidos", details: validation.error.errors },
{ status: 400 }
);
}
const data = validation.data;
// Verificar que el presupuesto existe y pertenece a la empresa
const presupuesto = await prisma.presupuesto.findFirst({
where: {
id: data.presupuestoId,
obra: {
empresaId: session.user.empresaId,
},
},
include: {
partidas: true,
},
});
if (!presupuesto) {
return NextResponse.json(
{ error: "Presupuesto no encontrado" },
{ status: 404 }
);
}
// Obtener el último número de estimación para este presupuesto
const ultimaEstimacion = await prisma.estimacion.findFirst({
where: { presupuestoId: data.presupuestoId },
orderBy: { numero: "desc" },
select: { numero: true },
});
const nuevoNumero = (ultimaEstimacion?.numero || 0) + 1;
// Obtener estimaciones anteriores para calcular acumulados
const estimacionesAnteriores = await prisma.estimacionPartida.findMany({
where: {
estimacion: {
presupuestoId: data.presupuestoId,
estado: { not: "RECHAZADA" },
},
},
select: {
partidaId: true,
cantidadEstimacion: true,
importeEstimacion: true,
},
});
// Calcular acumulados por partida
const acumuladosPorPartida: Record<string, { cantidad: number; importe: number }> = {};
for (const ep of estimacionesAnteriores) {
if (!acumuladosPorPartida[ep.partidaId]) {
acumuladosPorPartida[ep.partidaId] = { cantidad: 0, importe: 0 };
}
acumuladosPorPartida[ep.partidaId].cantidad += ep.cantidadEstimacion;
acumuladosPorPartida[ep.partidaId].importe += ep.importeEstimacion;
}
// Preparar partidas para cálculo
const partidasParaCalculo = data.partidas.map((p) => {
const partidaPresupuesto = presupuesto.partidas.find(
(pp) => pp.id === p.partidaId
);
if (!partidaPresupuesto) {
throw new Error(`Partida ${p.partidaId} no encontrada en el presupuesto`);
}
const acumulado = acumuladosPorPartida[p.partidaId] || { cantidad: 0, importe: 0 };
return {
cantidadEstimacion: p.cantidadEstimacion,
precioUnitario: partidaPresupuesto.precioUnitario,
cantidadAnterior: acumulado.cantidad,
cantidadContrato: partidaPresupuesto.cantidad,
};
});
// Calcular totales
const totales = calcularTotalesEstimacion(partidasParaCalculo, {
porcentajeRetencion: data.porcentajeRetencion || 5,
porcentajeIVA: data.porcentajeIVA || 16,
amortizacion: data.amortizacion || 0,
deduccionesVarias: data.deduccionesVarias || 0,
});
// Crear estimación con partidas
const estimacion = await prisma.estimacion.create({
data: {
numero: nuevoNumero,
periodo: data.periodo,
fechaInicio: new Date(data.fechaInicio),
fechaFin: new Date(data.fechaFin),
porcentajeRetencion: data.porcentajeRetencion || 5,
porcentajeIVA: data.porcentajeIVA || 16,
amortizacion: data.amortizacion || 0,
deduccionesVarias: data.deduccionesVarias || 0,
observaciones: data.observaciones,
importeEjecutado: totales.importeEjecutado,
importeAnterior: totales.importeAnterior,
importeAcumulado: totales.importeAcumulado,
retencion: totales.retencion,
subtotal: totales.subtotal,
iva: totales.iva,
total: totales.total,
importeNeto: totales.importeNeto,
presupuestoId: data.presupuestoId,
partidas: {
create: data.partidas.map((p) => {
const partidaPresupuesto = presupuesto.partidas.find(
(pp) => pp.id === p.partidaId
)!;
const acumulado = acumuladosPorPartida[p.partidaId] || { cantidad: 0, importe: 0 };
const cantidadAcumulada = acumulado.cantidad + p.cantidadEstimacion;
const importeEstimacion = p.cantidadEstimacion * partidaPresupuesto.precioUnitario;
const importeAcumulado = acumulado.importe + importeEstimacion;
const porcentajeAnterior = (acumulado.cantidad / partidaPresupuesto.cantidad) * 100;
const porcentajeEstimacion = (p.cantidadEstimacion / partidaPresupuesto.cantidad) * 100;
const porcentajeAcumulado = (cantidadAcumulada / partidaPresupuesto.cantidad) * 100;
return {
partidaId: p.partidaId,
cantidadContrato: partidaPresupuesto.cantidad,
cantidadAnterior: acumulado.cantidad,
cantidadEstimacion: p.cantidadEstimacion,
cantidadAcumulada,
cantidadPendiente: partidaPresupuesto.cantidad - cantidadAcumulada,
precioUnitario: partidaPresupuesto.precioUnitario,
importeAnterior: acumulado.importe,
importeEstimacion,
importeAcumulado,
porcentajeAnterior,
porcentajeEstimacion,
porcentajeAcumulado,
notas: p.notas,
};
}),
},
},
include: {
partidas: {
include: {
partida: {
select: {
codigo: true,
descripcion: true,
unidad: true,
},
},
},
},
presupuesto: {
select: {
nombre: true,
obra: {
select: {
nombre: true,
},
},
},
},
},
});
return NextResponse.json(estimacion, { status: 201 });
} catch (error) {
console.error("Error al crear estimación:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Error al crear estimación" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,300 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { z } from "zod";
const updateTareaSchema = z.object({
tareaId: z.string(),
fechaInicio: z.string().datetime().optional(),
fechaFin: z.string().datetime().optional(),
porcentajeAvance: z.number().min(0).max(100).optional(),
});
// GET - Obtener programación de obra para Gantt
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id: obraId } = await params;
// Verificar acceso a la obra
const obra = await prisma.obra.findFirst({
where: {
id: obraId,
empresaId: session.user.empresaId,
},
select: {
id: true,
nombre: true,
fechaInicio: true,
fechaFinPrevista: true,
porcentajeAvance: true,
},
});
if (!obra) {
return NextResponse.json({ error: "Obra no encontrada" }, { status: 404 });
}
// Obtener fases y tareas
const fases = await prisma.faseObra.findMany({
where: { obraId },
orderBy: { orden: "asc" },
include: {
tareas: {
orderBy: [{ prioridad: "desc" }, { fechaInicio: "asc" }],
include: {
asignado: {
select: { nombre: true, apellido: true },
},
},
},
},
});
// Convertir a formato Gantt
const tasks: {
id: string;
type: "project" | "task";
name: string;
start: Date;
end: Date;
progress: number;
project?: string;
dependencies?: string[];
styles?: { backgroundColor?: string; progressColor?: string };
hideChildren?: boolean;
displayOrder: number;
}[] = [];
let displayOrder = 0;
// Añadir la obra como proyecto principal si tiene fechas
if (obra.fechaInicio && obra.fechaFinPrevista) {
tasks.push({
id: `obra-${obra.id}`,
type: "project",
name: obra.nombre,
start: obra.fechaInicio,
end: obra.fechaFinPrevista,
progress: obra.porcentajeAvance,
hideChildren: false,
displayOrder: displayOrder++,
styles: {
backgroundColor: "#1e40af",
progressColor: "#3b82f6",
},
});
}
// Colores para las fases
const faseColors = [
{ bg: "#0891b2", progress: "#22d3ee" }, // cyan
{ bg: "#7c3aed", progress: "#a78bfa" }, // violet
{ bg: "#059669", progress: "#34d399" }, // emerald
{ bg: "#d97706", progress: "#fbbf24" }, // amber
{ bg: "#dc2626", progress: "#f87171" }, // red
{ bg: "#2563eb", progress: "#60a5fa" }, // blue
];
for (let faseIndex = 0; faseIndex < fases.length; faseIndex++) {
const fase = fases[faseIndex];
const faseColor = faseColors[faseIndex % faseColors.length];
// Calcular fechas de la fase basadas en sus tareas si no tiene fechas propias
let faseStart = fase.fechaInicio;
let faseEnd = fase.fechaFin;
if (fase.tareas.length > 0) {
const tareasConFechas = fase.tareas.filter(
(t) => t.fechaInicio && t.fechaFin
);
if (tareasConFechas.length > 0) {
const starts = tareasConFechas.map((t) => t.fechaInicio!.getTime());
const ends = tareasConFechas.map((t) => t.fechaFin!.getTime());
if (!faseStart) faseStart = new Date(Math.min(...starts));
if (!faseEnd) faseEnd = new Date(Math.max(...ends));
}
}
// Si aún no hay fechas, usar fechas por defecto
if (!faseStart) faseStart = obra.fechaInicio || new Date();
if (!faseEnd) {
faseEnd = new Date(faseStart);
faseEnd.setDate(faseEnd.getDate() + 30);
}
// Añadir fase como proyecto
tasks.push({
id: `fase-${fase.id}`,
type: "project",
name: fase.nombre,
start: faseStart,
end: faseEnd,
progress: fase.porcentajeAvance,
project: obra.fechaInicio ? `obra-${obra.id}` : undefined,
hideChildren: false,
displayOrder: displayOrder++,
styles: {
backgroundColor: faseColor.bg,
progressColor: faseColor.progress,
},
});
// Añadir tareas de la fase
for (const tarea of fase.tareas) {
let tareaStart = tarea.fechaInicio || faseStart;
let tareaEnd = tarea.fechaFin;
if (!tareaEnd) {
tareaEnd = new Date(tareaStart);
tareaEnd.setDate(tareaEnd.getDate() + 7);
}
tasks.push({
id: `tarea-${tarea.id}`,
type: "task",
name: tarea.nombre,
start: tareaStart,
end: tareaEnd,
progress: tarea.porcentajeAvance,
project: `fase-${fase.id}`,
displayOrder: displayOrder++,
styles: {
backgroundColor: "#64748b",
progressColor: "#94a3b8",
},
});
}
}
return NextResponse.json({
obra: {
id: obra.id,
nombre: obra.nombre,
fechaInicio: obra.fechaInicio,
fechaFinPrevista: obra.fechaFinPrevista,
porcentajeAvance: obra.porcentajeAvance,
},
fases: fases.map((f) => ({
id: f.id,
nombre: f.nombre,
orden: f.orden,
fechaInicio: f.fechaInicio,
fechaFin: f.fechaFin,
porcentajeAvance: f.porcentajeAvance,
tareas: f.tareas.map((t) => ({
id: t.id,
nombre: t.nombre,
estado: t.estado,
prioridad: t.prioridad,
fechaInicio: t.fechaInicio,
fechaFin: t.fechaFin,
porcentajeAvance: t.porcentajeAvance,
asignado: t.asignado
? `${t.asignado.nombre} ${t.asignado.apellido}`
: null,
})),
})),
ganttTasks: tasks,
});
} catch (error) {
console.error("Error al obtener programación:", error);
return NextResponse.json(
{ error: "Error al obtener programación" },
{ status: 500 }
);
}
}
// PUT - Actualizar fechas de tarea (desde drag & drop del Gantt)
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id: obraId } = await params;
const body = await request.json();
const validation = updateTareaSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Datos inválidos", details: validation.error.errors },
{ status: 400 }
);
}
const { tareaId, fechaInicio, fechaFin, porcentajeAvance } = validation.data;
// Verificar acceso
const tarea = await prisma.tareaObra.findFirst({
where: {
id: tareaId,
fase: {
obraId,
obra: { empresaId: session.user.empresaId },
},
},
});
if (!tarea) {
return NextResponse.json({ error: "Tarea no encontrada" }, { status: 404 });
}
// Actualizar tarea
const updateData: {
fechaInicio?: Date;
fechaFin?: Date;
porcentajeAvance?: number;
} = {};
if (fechaInicio) updateData.fechaInicio = new Date(fechaInicio);
if (fechaFin) updateData.fechaFin = new Date(fechaFin);
if (porcentajeAvance !== undefined)
updateData.porcentajeAvance = porcentajeAvance;
const updatedTarea = await prisma.tareaObra.update({
where: { id: tareaId },
data: updateData,
});
// Recalcular avance de la fase
const fase = await prisma.faseObra.findUnique({
where: { id: tarea.faseId },
include: { tareas: true },
});
if (fase && fase.tareas.length > 0) {
const promedioAvance =
fase.tareas.reduce((sum, t) => sum + t.porcentajeAvance, 0) /
fase.tareas.length;
await prisma.faseObra.update({
where: { id: fase.id },
data: { porcentajeAvance: promedioAvance },
});
}
return NextResponse.json({
success: true,
tarea: updatedTarea,
});
} catch (error) {
console.error("Error al actualizar tarea:", error);
return NextResponse.json(
{ error: "Error al actualizar tarea" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,320 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { z } from "zod";
const actualizacionSchema = z.object({
tipo: z.enum(["MATERIAL", "MANO_OBRA", "EQUIPO"]),
metodo: z.enum(["PORCENTAJE", "VALOR_FIJO"]),
valor: z.number(),
ids: z.array(z.string()).optional(), // Si está vacío, actualiza todos
actualizarAPUs: z.boolean().default(false),
});
// POST - Actualización masiva de precios
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validation = actualizacionSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Datos inválidos", details: validation.error.errors },
{ status: 400 }
);
}
const { tipo, metodo, valor, ids, actualizarAPUs } = validation.data;
const empresaId = session.user.empresaId;
let actualizados = 0;
let apusActualizados = 0;
// Función para calcular nuevo precio
const calcularNuevoPrecio = (precioActual: number): number => {
if (metodo === "PORCENTAJE") {
return precioActual * (1 + valor / 100);
}
return valor;
};
// Actualizar según el tipo
if (tipo === "MATERIAL") {
const whereClause: Record<string, unknown> = { empresaId };
if (ids && ids.length > 0) {
whereClause.id = { in: ids };
}
const materiales = await prisma.material.findMany({
where: whereClause,
select: { id: true, precioUnitario: true },
});
for (const material of materiales) {
const nuevoPrecio = calcularNuevoPrecio(material.precioUnitario);
await prisma.material.update({
where: { id: material.id },
data: { precioUnitario: nuevoPrecio },
});
actualizados++;
}
// Actualizar APUs que usan estos materiales
if (actualizarAPUs) {
const materialIds = materiales.map((m) => m.id);
const insumosAfectados = await prisma.insumoAPU.findMany({
where: {
materialId: { in: materialIds },
apu: { empresaId },
},
include: {
material: true,
apu: {
include: {
insumos: true,
},
},
},
});
const apusParaActualizar = new Set<string>();
for (const insumo of insumosAfectados) {
if (insumo.material) {
// Actualizar precio en el insumo
const nuevoImporte =
insumo.cantidadConDesperdicio * insumo.material.precioUnitario;
await prisma.insumoAPU.update({
where: { id: insumo.id },
data: {
precioUnitario: insumo.material.precioUnitario,
importe: nuevoImporte,
},
});
apusParaActualizar.add(insumo.apuId);
}
}
// Recalcular totales de APUs
for (const apuId of Array.from(apusParaActualizar)) {
await recalcularAPU(apuId, empresaId);
apusActualizados++;
}
}
} else if (tipo === "MANO_OBRA") {
const whereClause: Record<string, unknown> = { empresaId };
if (ids && ids.length > 0) {
whereClause.id = { in: ids };
}
const categorias = await prisma.categoriaTrabajoAPU.findMany({
where: whereClause,
select: {
id: true,
salarioDiario: true,
factorIMSS: true,
factorINFONAVIT: true,
factorRetiro: true,
factorVacaciones: true,
factorPrimaVac: true,
factorAguinaldo: true,
},
});
for (const cat of categorias) {
const nuevoSalario = calcularNuevoPrecio(cat.salarioDiario);
const fsr =
1 +
cat.factorIMSS +
cat.factorINFONAVIT +
cat.factorRetiro +
cat.factorVacaciones +
cat.factorPrimaVac +
cat.factorAguinaldo;
const salarioReal = nuevoSalario * fsr;
await prisma.categoriaTrabajoAPU.update({
where: { id: cat.id },
data: {
salarioDiario: nuevoSalario,
salarioReal: salarioReal,
},
});
actualizados++;
}
// Actualizar APUs
if (actualizarAPUs) {
const catIds = categorias.map((c) => c.id);
const insumosAfectados = await prisma.insumoAPU.findMany({
where: {
categoriaManoObraId: { in: catIds },
apu: { empresaId },
},
include: {
categoriaManoObra: true,
},
});
const apusParaActualizar = new Set<string>();
for (const insumo of insumosAfectados) {
if (insumo.categoriaManoObra && insumo.rendimiento) {
const nuevoImporte =
(1 / insumo.rendimiento) * insumo.categoriaManoObra.salarioReal;
await prisma.insumoAPU.update({
where: { id: insumo.id },
data: {
precioUnitario: insumo.categoriaManoObra.salarioReal,
importe: nuevoImporte,
},
});
apusParaActualizar.add(insumo.apuId);
}
}
for (const apuId of Array.from(apusParaActualizar)) {
await recalcularAPU(apuId, empresaId);
apusActualizados++;
}
}
} else if (tipo === "EQUIPO") {
const whereClause: Record<string, unknown> = { empresaId };
if (ids && ids.length > 0) {
whereClause.id = { in: ids };
}
const equipos = await prisma.equipoMaquinaria.findMany({
where: whereClause,
select: {
id: true,
costoHorario: true,
valorAdquisicion: true,
vidaUtilHoras: true,
valorRescate: true,
consumoCombustible: true,
precioCombustible: true,
factorMantenimiento: true,
costoOperador: true,
},
});
for (const equipo of equipos) {
// Para equipos, actualizamos el costo horario directamente
const nuevoCostoHorario = calcularNuevoPrecio(equipo.costoHorario);
await prisma.equipoMaquinaria.update({
where: { id: equipo.id },
data: { costoHorario: nuevoCostoHorario },
});
actualizados++;
}
// Actualizar APUs
if (actualizarAPUs) {
const equipoIds = equipos.map((e) => e.id);
const insumosAfectados = await prisma.insumoAPU.findMany({
where: {
equipoId: { in: equipoIds },
apu: { empresaId },
},
include: {
equipo: true,
},
});
const apusParaActualizar = new Set<string>();
for (const insumo of insumosAfectados) {
if (insumo.equipo) {
const nuevoImporte =
insumo.cantidadConDesperdicio * insumo.equipo.costoHorario;
await prisma.insumoAPU.update({
where: { id: insumo.id },
data: {
precioUnitario: insumo.equipo.costoHorario,
importe: nuevoImporte,
},
});
apusParaActualizar.add(insumo.apuId);
}
}
for (const apuId of Array.from(apusParaActualizar)) {
await recalcularAPU(apuId, empresaId);
apusActualizados++;
}
}
}
return NextResponse.json({
success: true,
actualizados,
apusActualizados,
mensaje: `${actualizados} registros actualizados${
apusActualizados > 0 ? `, ${apusActualizados} APUs recalculados` : ""
}`,
});
} catch (error) {
console.error("Error en actualización masiva:", error);
return NextResponse.json(
{ error: "Error al actualizar precios" },
{ status: 500 }
);
}
}
// Helper para recalcular totales de un APU
async function recalcularAPU(apuId: string, empresaId: string) {
const apu = await prisma.analisisPrecioUnitario.findFirst({
where: { id: apuId, empresaId },
include: {
insumos: true,
},
});
if (!apu) return;
// Obtener configuración
const config = await prisma.configuracionAPU.findFirst({
where: { empresaId },
});
const porcentajeHerramienta = config?.porcentajeHerramientaMenor ?? 3;
const porcentajeIndirectos = apu.porcentajeIndirectos || config?.porcentajeIndirectos || 8;
const porcentajeUtilidad = apu.porcentajeUtilidad || config?.porcentajeUtilidad || 10;
// Calcular costos por tipo
const costoMateriales = apu.insumos
.filter((i) => i.tipo === "MATERIAL")
.reduce((sum, i) => sum + i.importe, 0);
const costoManoObra = apu.insumos
.filter((i) => i.tipo === "MANO_OBRA")
.reduce((sum, i) => sum + i.importe, 0);
const costoEquipo = apu.insumos
.filter((i) => i.tipo === "EQUIPO")
.reduce((sum, i) => sum + i.importe, 0);
const costoHerramienta = costoManoObra * (porcentajeHerramienta / 100);
const costoDirecto = costoMateriales + costoManoObra + costoEquipo + costoHerramienta;
const costoIndirectos = costoDirecto * (porcentajeIndirectos / 100);
const costoUtilidad = (costoDirecto + costoIndirectos) * (porcentajeUtilidad / 100);
const precioUnitario = costoDirecto + costoIndirectos + costoUtilidad;
await prisma.analisisPrecioUnitario.update({
where: { id: apuId },
data: {
costoMateriales,
costoManoObra,
costoEquipo,
costoHerramienta,
costoDirecto,
costoIndirectos,
costoUtilidad,
precioUnitario,
},
});
}

View File

@@ -52,6 +52,7 @@ import {
import { UNIDAD_MEDIDA_LABELS } from "@/types"; import { UNIDAD_MEDIDA_LABELS } from "@/types";
import { UnidadMedida } from "@prisma/client"; import { UnidadMedida } from "@prisma/client";
import { ImportadorCatalogo } from "@/components/importar"; import { ImportadorCatalogo } from "@/components/importar";
import { ActualizacionMasiva } from "@/components/precios";
interface APU { interface APU {
id: string; id: string;
@@ -195,6 +196,7 @@ export function APUList({ apus: initialApus }: APUListProps) {
)} )}
</Button> </Button>
<ImportadorCatalogo /> <ImportadorCatalogo />
<ActualizacionMasiva onComplete={() => router.refresh()} />
<Button asChild> <Button asChild>
<Link href="/apu/nuevo"> <Link href="/apu/nuevo">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />

View 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>
);
}

View File

@@ -0,0 +1 @@
export { ComparativoDashboard } from "./comparativo-dashboard";

View File

@@ -0,0 +1,644 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableFooter,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Progress } from "@/components/ui/progress";
import { toast } from "@/hooks/use-toast";
import { formatCurrency, formatPercentage } from "@/lib/utils";
import { ClientDate } from "@/components/ui/client-date";
import {
ESTADO_ESTIMACION_LABELS,
ESTADO_ESTIMACION_COLORS,
} from "@/lib/validations/estimaciones";
import { UNIDAD_MEDIDA_LABELS } from "@/types";
import {
ArrowLeft,
MoreVertical,
Pencil,
Trash2,
Send,
CheckCircle,
XCircle,
DollarSign,
FileText,
Printer,
Download,
} from "lucide-react";
import { EstadoEstimacion, UnidadMedida } from "@prisma/client";
interface EstimacionDetailProps {
estimacion: {
id: string;
numero: number;
periodo: string;
fechaInicio: string | Date;
fechaFin: string | Date;
fechaEmision: string | Date;
fechaEnvio: string | Date | null;
fechaAprobacion: string | Date | null;
estado: EstadoEstimacion;
importeEjecutado: number;
importeAcumulado: number;
importeAnterior: number;
amortizacion: number;
retencion: number;
porcentajeRetencion: number;
deduccionesVarias: number;
subtotal: number;
iva: number;
porcentajeIVA: number;
total: number;
importeNeto: number;
observaciones: string | null;
motivoRechazo: string | null;
presupuesto: {
id: string;
nombre: string;
total: number;
obra: {
id: string;
nombre: string;
cliente: {
nombre: string;
} | null;
};
};
partidas: Array<{
id: string;
cantidadContrato: number;
cantidadAnterior: number;
cantidadEstimacion: number;
cantidadAcumulada: number;
cantidadPendiente: number;
precioUnitario: number;
importeAnterior: number;
importeEstimacion: number;
importeAcumulado: number;
porcentajeAnterior: number;
porcentajeEstimacion: number;
porcentajeAcumulado: number;
notas: string | null;
partida: {
id: string;
codigo: string;
descripcion: string;
unidad: UnidadMedida;
};
}>;
};
}
export function EstimacionDetail({ estimacion }: EstimacionDetailProps) {
const router = useRouter();
const [estado, setEstado] = useState(estimacion.estado);
const [actionDialog, setActionDialog] = useState<{
open: boolean;
action: string;
title: string;
description: string;
}>({ open: false, action: "", title: "", description: "" });
const [isLoading, setIsLoading] = useState(false);
const handleAction = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/estimaciones/${estimacion.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: actionDialog.action }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al actualizar");
}
const updated = await response.json();
setEstado(updated.estado);
toast({
title: "Estado actualizado",
description: `La estimación ahora está ${ESTADO_ESTIMACION_LABELS[updated.estado]}`,
});
router.refresh();
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al actualizar",
variant: "destructive",
});
} finally {
setIsLoading(false);
setActionDialog({ open: false, action: "", title: "", description: "" });
}
};
const handleDelete = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/estimaciones/${estimacion.id}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al eliminar");
}
toast({
title: "Estimación eliminada",
description: "La estimación ha sido eliminada exitosamente",
});
router.push(`/obras/${estimacion.presupuesto.obra.id}`);
router.refresh();
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al eliminar",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const openActionDialog = (action: string, title: string, description: string) => {
setActionDialog({ open: true, action, title, description });
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/obras/${estimacion.presupuesto.obra.id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold">Estimación #{estimacion.numero}</h2>
<Badge className={ESTADO_ESTIMACION_COLORS[estado]}>
{ESTADO_ESTIMACION_LABELS[estado]}
</Badge>
</div>
<p className="text-muted-foreground">
{estimacion.presupuesto.obra.nombre} - {estimacion.presupuesto.nombre}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" title="Imprimir">
<Printer className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" title="Descargar PDF">
<Download className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{estado === "BORRADOR" && (
<>
<DropdownMenuItem asChild>
<Link href={`/obras/${estimacion.presupuesto.obra.id}/estimaciones/${estimacion.id}/editar`}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
openActionDialog(
"ENVIADA",
"Enviar Estimación",
"¿Desea enviar esta estimación para revisión?"
)
}
>
<Send className="mr-2 h-4 w-4" />
Enviar
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{estado === "ENVIADA" && (
<>
<DropdownMenuItem
onClick={() =>
openActionDialog(
"EN_REVISION",
"Poner en Revisión",
"¿Desea marcar esta estimación como en revisión?"
)
}
>
<FileText className="mr-2 h-4 w-4" />
Poner en Revisión
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
openActionDialog(
"RECHAZADA",
"Rechazar Estimación",
"¿Está seguro de rechazar esta estimación?"
)
}
className="text-red-600"
>
<XCircle className="mr-2 h-4 w-4" />
Rechazar
</DropdownMenuItem>
</>
)}
{estado === "EN_REVISION" && (
<>
<DropdownMenuItem
onClick={() =>
openActionDialog(
"APROBADA",
"Aprobar Estimación",
"¿Desea aprobar esta estimación?"
)
}
>
<CheckCircle className="mr-2 h-4 w-4" />
Aprobar
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
openActionDialog(
"RECHAZADA",
"Rechazar Estimación",
"¿Está seguro de rechazar esta estimación?"
)
}
className="text-red-600"
>
<XCircle className="mr-2 h-4 w-4" />
Rechazar
</DropdownMenuItem>
</>
)}
{estado === "APROBADA" && (
<DropdownMenuItem
onClick={() =>
openActionDialog(
"PAGADA",
"Marcar como Pagada",
"¿Confirma que esta estimación ha sido pagada?"
)
}
>
<DollarSign className="mr-2 h-4 w-4" />
Marcar como Pagada
</DropdownMenuItem>
)}
{estado === "RECHAZADA" && (
<DropdownMenuItem
onClick={() =>
openActionDialog(
"BORRADOR",
"Volver a Borrador",
"¿Desea regresar esta estimación a borrador para editarla?"
)
}
>
<Pencil className="mr-2 h-4 w-4" />
Volver a Borrador
</DropdownMenuItem>
)}
{["BORRADOR", "RECHAZADA"].includes(estado) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
openActionDialog(
"DELETE",
"Eliminar Estimación",
"Esta acción no se puede deshacer. ¿Está seguro de eliminar esta estimación?"
)
}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Periodo</CardDescription>
<CardTitle className="text-lg">{estimacion.periodo}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
<ClientDate date={estimacion.fechaInicio} /> -{" "}
<ClientDate date={estimacion.fechaFin} />
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Importe Ejecutado</CardDescription>
<CardTitle className="text-lg font-mono">
{formatCurrency(estimacion.importeEjecutado)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Acumulado: {formatCurrency(estimacion.importeAcumulado)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Deducciones</CardDescription>
<CardTitle className="text-lg font-mono text-amber-600">
{formatCurrency(
estimacion.retencion + estimacion.amortizacion + estimacion.deduccionesVarias
)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Ret: {formatCurrency(estimacion.retencion)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Importe Neto</CardDescription>
<CardTitle className="text-lg font-mono text-green-600">
{formatCurrency(estimacion.importeNeto)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
IVA: {formatCurrency(estimacion.iva)}
</p>
</CardContent>
</Card>
</div>
{/* Rejected reason */}
{estimacion.motivoRechazo && (
<Card className="border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="text-red-800">Motivo de Rechazo</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-700">{estimacion.motivoRechazo}</p>
</CardContent>
</Card>
)}
{/* Partidas Table */}
<Card>
<CardHeader>
<CardTitle>Desglose de Partidas</CardTitle>
<CardDescription>
{estimacion.partidas.length} partidas incluidas en esta estimación
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">Código</TableHead>
<TableHead>Descripción</TableHead>
<TableHead className="w-[70px]">Unidad</TableHead>
<TableHead className="w-[80px] text-right">Contrato</TableHead>
<TableHead className="w-[80px] text-right">Anterior</TableHead>
<TableHead className="w-[80px] text-right">Esta Est.</TableHead>
<TableHead className="w-[80px] text-right">Acumulado</TableHead>
<TableHead className="w-[100px] text-right">P.U.</TableHead>
<TableHead className="w-[120px] text-right">Importe</TableHead>
<TableHead className="w-[100px]">Avance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{estimacion.partidas.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-sm">
{item.partida.codigo}
</TableCell>
<TableCell className="max-w-[200px] truncate" title={item.partida.descripcion}>
{item.partida.descripcion}
</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[item.partida.unidad]}</TableCell>
<TableCell className="text-right font-mono">
{item.cantidadContrato.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-slate-500">
{item.cantidadAnterior.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono font-medium">
{item.cantidadEstimacion.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono">
{item.cantidadAcumulada.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(item.precioUnitario)}
</TableCell>
<TableCell className="text-right font-mono font-medium">
{formatCurrency(item.importeEstimacion)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress
value={Math.min(100, item.porcentajeAcumulado)}
className="h-2 w-12"
/>
<span className="text-xs text-slate-500">
{formatPercentage(item.porcentajeAcumulado)}
</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={8} className="text-right font-semibold">
Subtotal Partidas:
</TableCell>
<TableCell className="text-right font-mono font-bold">
{formatCurrency(estimacion.importeEjecutado)}
</TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</CardContent>
</Card>
{/* Resumen de Totales */}
<Card>
<CardHeader>
<CardTitle>Resumen de Totales</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-md ml-auto space-y-2">
<div className="flex justify-between py-2 border-b">
<span>Importe Ejecutado:</span>
<span className="font-mono">{formatCurrency(estimacion.importeEjecutado)}</span>
</div>
<div className="flex justify-between py-2 border-b text-slate-600">
<span>(-) Retención ({estimacion.porcentajeRetencion}%):</span>
<span className="font-mono">{formatCurrency(estimacion.retencion)}</span>
</div>
{estimacion.amortizacion > 0 && (
<div className="flex justify-between py-2 border-b text-slate-600">
<span>(-) Amortización:</span>
<span className="font-mono">{formatCurrency(estimacion.amortizacion)}</span>
</div>
)}
{estimacion.deduccionesVarias > 0 && (
<div className="flex justify-between py-2 border-b text-slate-600">
<span>(-) Otras Deducciones:</span>
<span className="font-mono">{formatCurrency(estimacion.deduccionesVarias)}</span>
</div>
)}
<div className="flex justify-between py-2 border-b">
<span>Subtotal:</span>
<span className="font-mono">{formatCurrency(estimacion.subtotal)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span>IVA ({estimacion.porcentajeIVA}%):</span>
<span className="font-mono">{formatCurrency(estimacion.iva)}</span>
</div>
<div className="flex justify-between py-3 border-t-2 text-lg font-bold">
<span>Total a Pagar:</span>
<span className="font-mono text-green-600">
{formatCurrency(estimacion.importeNeto)}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Observaciones */}
{estimacion.observaciones && (
<Card>
<CardHeader>
<CardTitle>Observaciones</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap">{estimacion.observaciones}</p>
</CardContent>
</Card>
)}
{/* Fechas */}
<Card>
<CardHeader>
<CardTitle>Historial</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Fecha de Emisión</p>
<p className="font-medium">
<ClientDate date={estimacion.fechaEmision} format="long" />
</p>
</div>
{estimacion.fechaEnvio && (
<div>
<p className="text-muted-foreground">Fecha de Envío</p>
<p className="font-medium">
<ClientDate date={estimacion.fechaEnvio} format="long" />
</p>
</div>
)}
{estimacion.fechaAprobacion && (
<div>
<p className="text-muted-foreground">Fecha de Aprobación</p>
<p className="font-medium">
<ClientDate date={estimacion.fechaAprobacion} format="long" />
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Action Dialog */}
<AlertDialog
open={actionDialog.open}
onOpenChange={(open) => !open && setActionDialog({ ...actionDialog, open: false })}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{actionDialog.title}</AlertDialogTitle>
<AlertDialogDescription>{actionDialog.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={actionDialog.action === "DELETE" ? handleDelete : handleAction}
disabled={isLoading}
className={actionDialog.action === "DELETE" || actionDialog.action === "RECHAZADA"
? "bg-red-600 hover:bg-red-700"
: ""
}
>
{isLoading ? "Procesando..." : "Confirmar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,639 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { toast } from "@/hooks/use-toast";
import { formatCurrency, formatPercentage } from "@/lib/utils";
import { UNIDAD_MEDIDA_LABELS } from "@/types";
import { Loader2, Calculator, Save, ArrowLeft } from "lucide-react";
import { UnidadMedida } from "@prisma/client";
import Link from "next/link";
const formSchema = z.object({
periodo: z.string().min(1, "El periodo es requerido"),
fechaInicio: z.string().min(1, "La fecha de inicio es requerida"),
fechaFin: z.string().min(1, "La fecha de fin es requerida"),
porcentajeRetencion: z.coerce.number().min(0).max(100),
porcentajeIVA: z.coerce.number().min(0).max(100),
amortizacion: z.coerce.number().min(0),
deduccionesVarias: z.coerce.number().min(0),
observaciones: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
interface Partida {
id: string;
codigo: string;
descripcion: string;
unidad: UnidadMedida;
cantidad: number;
precioUnitario: number;
total: number;
}
interface PartidaConAvance extends Partida {
cantidadAnterior: number;
cantidadEstimacion: number;
cantidadAcumulada: number;
cantidadPendiente: number;
importeAnterior: number;
importeEstimacion: number;
importeAcumulado: number;
porcentajeAcumulado: number;
seleccionada: boolean;
}
interface EstimacionFormProps {
presupuesto: {
id: string;
nombre: string;
total: number;
partidas: Partida[];
obra: {
id: string;
nombre: string;
};
};
acumuladosAnteriores?: Record<string, { cantidad: number; importe: number }>;
estimacionExistente?: {
id: string;
numero: number;
periodo: string;
fechaInicio: string | Date;
fechaFin: string | Date;
porcentajeRetencion: number;
porcentajeIVA: number;
amortizacion: number;
deduccionesVarias: number;
observaciones: string | null;
partidas: Array<{
partidaId: string;
cantidadEstimacion: number;
notas: string | null;
}>;
};
}
export function EstimacionForm({
presupuesto,
acumuladosAnteriores = {},
estimacionExistente,
}: EstimacionFormProps) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [partidasEstimacion, setPartidasEstimacion] = useState<PartidaConAvance[]>(() => {
return presupuesto.partidas.map((p) => {
const acumulado = acumuladosAnteriores[p.id] || { cantidad: 0, importe: 0 };
const cantidadExistente = estimacionExistente?.partidas.find(
(ep) => ep.partidaId === p.id
)?.cantidadEstimacion || 0;
return {
...p,
cantidadAnterior: acumulado.cantidad,
cantidadEstimacion: cantidadExistente,
cantidadAcumulada: acumulado.cantidad + cantidadExistente,
cantidadPendiente: p.cantidad - acumulado.cantidad - cantidadExistente,
importeAnterior: acumulado.importe,
importeEstimacion: cantidadExistente * p.precioUnitario,
importeAcumulado: acumulado.importe + cantidadExistente * p.precioUnitario,
porcentajeAcumulado: ((acumulado.cantidad + cantidadExistente) / p.cantidad) * 100,
seleccionada: cantidadExistente > 0,
};
});
});
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
periodo: estimacionExistente?.periodo || "",
fechaInicio: estimacionExistente?.fechaInicio
? new Date(estimacionExistente.fechaInicio).toISOString().split("T")[0]
: "",
fechaFin: estimacionExistente?.fechaFin
? new Date(estimacionExistente.fechaFin).toISOString().split("T")[0]
: "",
porcentajeRetencion: estimacionExistente?.porcentajeRetencion ?? 5,
porcentajeIVA: estimacionExistente?.porcentajeIVA ?? 16,
amortizacion: estimacionExistente?.amortizacion ?? 0,
deduccionesVarias: estimacionExistente?.deduccionesVarias ?? 0,
observaciones: estimacionExistente?.observaciones || "",
},
});
const porcentajeRetencion = form.watch("porcentajeRetencion");
const porcentajeIVA = form.watch("porcentajeIVA");
const amortizacion = form.watch("amortizacion");
const deduccionesVarias = form.watch("deduccionesVarias");
// Calcular totales en tiempo real
const totales = useMemo(() => {
const partidasSeleccionadas = partidasEstimacion.filter((p) => p.seleccionada);
const importeEjecutado = partidasSeleccionadas.reduce(
(sum, p) => sum + p.importeEstimacion,
0
);
const importeAnterior = partidasSeleccionadas.reduce(
(sum, p) => sum + p.importeAnterior,
0
);
const importeAcumulado = importeAnterior + importeEjecutado;
const retencion = importeEjecutado * ((porcentajeRetencion || 0) / 100);
const subtotal = importeEjecutado - retencion - (amortizacion || 0) - (deduccionesVarias || 0);
const iva = subtotal * ((porcentajeIVA || 0) / 100);
const total = subtotal + iva;
return {
importeEjecutado,
importeAnterior,
importeAcumulado,
retencion,
subtotal,
iva,
total,
partidasCount: partidasSeleccionadas.filter((p) => p.cantidadEstimacion > 0).length,
};
}, [partidasEstimacion, porcentajeRetencion, porcentajeIVA, amortizacion, deduccionesVarias]);
const handleCantidadChange = (partidaId: string, value: string) => {
const cantidad = parseFloat(value) || 0;
setPartidasEstimacion((prev) =>
prev.map((p) => {
if (p.id !== partidaId) return p;
const cantidadAcumulada = p.cantidadAnterior + cantidad;
const importeEstimacion = cantidad * p.precioUnitario;
const importeAcumulado = p.importeAnterior + importeEstimacion;
const porcentajeAcumulado = (cantidadAcumulada / p.cantidad) * 100;
return {
...p,
cantidadEstimacion: cantidad,
cantidadAcumulada,
cantidadPendiente: p.cantidad - cantidadAcumulada,
importeEstimacion,
importeAcumulado,
porcentajeAcumulado,
seleccionada: cantidad > 0,
};
})
);
};
const handleSelectAll = (checked: boolean) => {
setPartidasEstimacion((prev) =>
prev.map((p) => ({
...p,
seleccionada: checked,
cantidadEstimacion: checked ? (p.cantidadEstimacion || 0) : p.cantidadEstimacion,
}))
);
};
const handleSelectPartida = (partidaId: string, checked: boolean) => {
setPartidasEstimacion((prev) =>
prev.map((p) => (p.id === partidaId ? { ...p, seleccionada: checked } : p))
);
};
const handleEstimarPendiente = (partidaId: string) => {
setPartidasEstimacion((prev) =>
prev.map((p) => {
if (p.id !== partidaId) return p;
const cantidad = Math.max(0, p.cantidad - p.cantidadAnterior);
const cantidadAcumulada = p.cantidadAnterior + cantidad;
const importeEstimacion = cantidad * p.precioUnitario;
const importeAcumulado = p.importeAnterior + importeEstimacion;
return {
...p,
cantidadEstimacion: cantidad,
cantidadAcumulada,
cantidadPendiente: 0,
importeEstimacion,
importeAcumulado,
porcentajeAcumulado: 100,
seleccionada: true,
};
})
);
};
const onSubmit = async (data: FormData) => {
const partidasConCantidad = partidasEstimacion.filter(
(p) => p.seleccionada && p.cantidadEstimacion > 0
);
if (partidasConCantidad.length === 0) {
toast({
title: "Error",
description: "Debe incluir al menos una partida con cantidad",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const payload = {
...data,
presupuestoId: presupuesto.id,
partidas: partidasConCantidad.map((p) => ({
partidaId: p.id,
cantidadEstimacion: p.cantidadEstimacion,
})),
};
const url = estimacionExistente
? `/api/estimaciones/${estimacionExistente.id}`
: "/api/estimaciones";
const response = await fetch(url, {
method: estimacionExistente ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al guardar");
}
const estimacion = await response.json();
toast({
title: estimacionExistente ? "Estimación actualizada" : "Estimación creada",
description: `Estimación #${estimacion.numero} guardada exitosamente`,
});
router.push(`/obras/${presupuesto.obra.id}/estimaciones/${estimacion.id}`);
router.refresh();
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al guardar",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href={`/obras/${presupuesto.obra.id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h2 className="text-2xl font-bold">
{estimacionExistente
? `Editar Estimación #${estimacionExistente.numero}`
: "Nueva Estimación"}
</h2>
<p className="text-muted-foreground">
{presupuesto.obra.nombre} - {presupuesto.nombre}
</p>
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Datos Generales */}
<Card>
<CardHeader>
<CardTitle>Datos de la Estimación</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<FormField
control={form.control}
name="periodo"
render={({ field }) => (
<FormItem className="lg:col-span-2">
<FormLabel>Periodo</FormLabel>
<FormControl>
<Input placeholder="Ej: 15-31 Enero 2024" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fechaInicio"
render={({ field }) => (
<FormItem>
<FormLabel>Fecha Inicio</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fechaFin"
render={({ field }) => (
<FormItem>
<FormLabel>Fecha Fin</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Partidas */}
<Card>
<CardHeader>
<CardTitle>Partidas a Estimar</CardTitle>
<CardDescription>
Ingrese las cantidades ejecutadas en este periodo
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={partidasEstimacion.every((p) => p.seleccionada)}
onCheckedChange={(checked) => handleSelectAll(!!checked)}
/>
</TableHead>
<TableHead className="w-[80px]">Código</TableHead>
<TableHead>Descripción</TableHead>
<TableHead className="w-[80px]">Unidad</TableHead>
<TableHead className="w-[100px] text-right">Contrato</TableHead>
<TableHead className="w-[100px] text-right">Anterior</TableHead>
<TableHead className="w-[120px] text-right">Esta Est.</TableHead>
<TableHead className="w-[100px] text-right">Acumulado</TableHead>
<TableHead className="w-[120px] text-right">Importe</TableHead>
<TableHead className="w-[100px]">Avance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{partidasEstimacion.map((partida) => (
<TableRow
key={partida.id}
className={partida.seleccionada ? "" : "opacity-50"}
>
<TableCell>
<Checkbox
checked={partida.seleccionada}
onCheckedChange={(checked) =>
handleSelectPartida(partida.id, !!checked)
}
/>
</TableCell>
<TableCell className="font-mono text-sm">
{partida.codigo}
</TableCell>
<TableCell className="max-w-[200px] truncate" title={partida.descripcion}>
{partida.descripcion}
</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[partida.unidad]}</TableCell>
<TableCell className="text-right font-mono">
{partida.cantidad.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-slate-500">
{partida.cantidadAnterior.toFixed(2)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
step="0.01"
max={partida.cantidadPendiente + partida.cantidadEstimacion}
value={partida.cantidadEstimacion || ""}
onChange={(e) =>
handleCantidadChange(partida.id, e.target.value)
}
className="w-20 text-right h-8"
disabled={!partida.seleccionada}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEstimarPendiente(partida.id)}
title="Estimar pendiente"
>
<Calculator className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell className="text-right font-mono">
{partida.cantidadAcumulada.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono font-medium">
{formatCurrency(partida.importeEstimacion)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress
value={Math.min(100, partida.porcentajeAcumulado)}
className="h-2 w-16"
/>
<span className="text-xs text-slate-500">
{formatPercentage(partida.porcentajeAcumulado)}
</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Deducciones y Totales */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Deducciones</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="porcentajeRetencion"
render={({ field }) => (
<FormItem>
<FormLabel>% Retención</FormLabel>
<FormControl>
<Input type="number" min="0" max="100" step="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amortizacion"
render={({ field }) => (
<FormItem>
<FormLabel>Amortización de Anticipo</FormLabel>
<FormControl>
<Input type="number" min="0" step="0.01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="deduccionesVarias"
render={({ field }) => (
<FormItem>
<FormLabel>Otras Deducciones</FormLabel>
<FormControl>
<Input type="number" min="0" step="0.01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="porcentajeIVA"
render={({ field }) => (
<FormItem>
<FormLabel>% IVA</FormLabel>
<FormControl>
<Input type="number" min="0" max="100" step="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="observaciones"
render={({ field }) => (
<FormItem>
<FormLabel>Observaciones</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Resumen de la Estimación</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between py-2 border-b">
<span className="text-slate-600">Partidas incluidas:</span>
<span className="font-medium">{totales.partidasCount}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-slate-600">Importe Ejecutado:</span>
<span className="font-mono">{formatCurrency(totales.importeEjecutado)}</span>
</div>
<div className="flex justify-between py-2 border-b text-slate-500">
<span>Retención ({porcentajeRetencion}%):</span>
<span className="font-mono">- {formatCurrency(totales.retencion)}</span>
</div>
{amortizacion > 0 && (
<div className="flex justify-between py-2 border-b text-slate-500">
<span>Amortización:</span>
<span className="font-mono">- {formatCurrency(amortizacion)}</span>
</div>
)}
{deduccionesVarias > 0 && (
<div className="flex justify-between py-2 border-b text-slate-500">
<span>Otras Deducciones:</span>
<span className="font-mono">- {formatCurrency(deduccionesVarias)}</span>
</div>
)}
<div className="flex justify-between py-2 border-b">
<span className="text-slate-600">Subtotal:</span>
<span className="font-mono">{formatCurrency(totales.subtotal)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-slate-600">IVA ({porcentajeIVA}%):</span>
<span className="font-mono">{formatCurrency(totales.iva)}</span>
</div>
<div className="flex justify-between py-3 border-t-2 border-slate-300">
<span className="text-lg font-semibold">Total a Pagar:</span>
<span className="text-lg font-bold font-mono text-green-600">
{formatCurrency(totales.total)}
</span>
</div>
</CardContent>
</Card>
</div>
{/* Botones */}
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
{estimacionExistente ? "Guardar Cambios" : "Crear Estimación"}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,416 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "@/hooks/use-toast";
import {
Plus,
Search,
MoreHorizontal,
Eye,
Pencil,
Trash2,
FileText,
Send,
CheckCircle,
XCircle,
DollarSign,
} from "lucide-react";
import { formatCurrency } from "@/lib/utils";
import { ClientDate } from "@/components/ui/client-date";
import {
ESTADO_ESTIMACION_LABELS,
ESTADO_ESTIMACION_COLORS,
} from "@/lib/validations/estimaciones";
import { EstadoEstimacion } from "@prisma/client";
interface Estimacion {
id: string;
numero: number;
periodo: string;
fechaEmision: string | Date;
estado: EstadoEstimacion;
importeEjecutado: number;
importeAcumulado: number;
importeNeto: number;
presupuesto: {
id: string;
nombre: string;
obra: {
id: string;
nombre: string;
};
};
_count: {
partidas: number;
};
}
interface EstimacionesListProps {
estimaciones: Estimacion[];
presupuestoId?: string;
obraId?: string;
}
export function EstimacionesList({
estimaciones: initialEstimaciones,
presupuestoId,
obraId,
}: EstimacionesListProps) {
const router = useRouter();
const [estimaciones, setEstimaciones] = useState(initialEstimaciones);
const [search, setSearch] = useState("");
const [estadoFilter, setEstadoFilter] = useState<string>("all");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [actionType, setActionType] = useState<string>("");
const filteredEstimaciones = useMemo(() => {
return estimaciones.filter((est) => {
const matchesSearch =
est.periodo.toLowerCase().includes(search.toLowerCase()) ||
est.presupuesto.nombre.toLowerCase().includes(search.toLowerCase()) ||
est.presupuesto.obra.nombre.toLowerCase().includes(search.toLowerCase());
const matchesEstado =
estadoFilter === "all" || est.estado === estadoFilter;
return matchesSearch && matchesEstado;
});
}, [estimaciones, search, estadoFilter]);
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/estimaciones/${deleteId}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al eliminar");
}
setEstimaciones(estimaciones.filter((e) => e.id !== deleteId));
toast({
title: "Estimación eliminada",
description: "La estimación ha sido eliminada exitosamente",
});
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "No se pudo eliminar",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const handleChangeEstado = async () => {
if (!actionId || !actionType) return;
try {
const response = await fetch(`/api/estimaciones/${actionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: actionType }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al actualizar estado");
}
const updated = await response.json();
setEstimaciones(
estimaciones.map((e) => (e.id === actionId ? { ...e, estado: updated.estado } : e))
);
toast({
title: "Estado actualizado",
description: `La estimación ahora está ${ESTADO_ESTIMACION_LABELS[updated.estado]}`,
});
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "No se pudo actualizar",
variant: "destructive",
});
} finally {
setActionId(null);
setActionType("");
}
};
const getNextActions = (estado: EstadoEstimacion) => {
const actions: Array<{ estado: string; label: string; icon: React.ReactNode }> = [];
switch (estado) {
case "BORRADOR":
actions.push({ estado: "ENVIADA", label: "Enviar", icon: <Send className="mr-2 h-4 w-4" /> });
break;
case "ENVIADA":
actions.push({ estado: "EN_REVISION", label: "Poner en revisión", icon: <FileText className="mr-2 h-4 w-4" /> });
actions.push({ estado: "RECHAZADA", label: "Rechazar", icon: <XCircle className="mr-2 h-4 w-4" /> });
break;
case "EN_REVISION":
actions.push({ estado: "APROBADA", label: "Aprobar", icon: <CheckCircle className="mr-2 h-4 w-4" /> });
actions.push({ estado: "RECHAZADA", label: "Rechazar", icon: <XCircle className="mr-2 h-4 w-4" /> });
break;
case "APROBADA":
actions.push({ estado: "PAGADA", label: "Marcar pagada", icon: <DollarSign className="mr-2 h-4 w-4" /> });
break;
case "RECHAZADA":
actions.push({ estado: "BORRADOR", label: "Volver a borrador", icon: <Pencil className="mr-2 h-4 w-4" /> });
break;
}
return actions;
};
const createUrl = presupuestoId
? `/obras/${obraId}/estimaciones/nueva?presupuestoId=${presupuestoId}`
: `/estimaciones/nueva`;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Buscar por periodo u obra..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2">
<Select value={estadoFilter} onValueChange={setEstadoFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Todos los estados" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los estados</SelectItem>
{Object.entries(ESTADO_ESTIMACION_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{presupuestoId && (
<Button asChild>
<Link href={createUrl}>
<Plus className="mr-2 h-4 w-4" />
Nueva Estimación
</Link>
</Button>
)}
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">No.</TableHead>
<TableHead>Periodo</TableHead>
{!presupuestoId && <TableHead>Obra / Presupuesto</TableHead>}
<TableHead className="w-[120px]">Fecha</TableHead>
<TableHead className="w-[150px] text-right">Ejecutado</TableHead>
<TableHead className="w-[150px] text-right">Neto</TableHead>
<TableHead className="w-[120px]">Estado</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEstimaciones.length === 0 ? (
<TableRow>
<TableCell colSpan={presupuestoId ? 7 : 8} className="h-24 text-center">
{search || estadoFilter !== "all"
? "No se encontraron resultados"
: "No hay estimaciones registradas"}
</TableCell>
</TableRow>
) : (
filteredEstimaciones.map((est) => (
<TableRow key={est.id}>
<TableCell className="font-medium">#{est.numero}</TableCell>
<TableCell>
<Link
href={`/obras/${est.presupuesto.obra.id}/estimaciones/${est.id}`}
className="hover:text-blue-600 hover:underline"
>
{est.periodo}
</Link>
</TableCell>
{!presupuestoId && (
<TableCell>
<div className="text-sm">
<div className="font-medium">{est.presupuesto.obra.nombre}</div>
<div className="text-slate-500">{est.presupuesto.nombre}</div>
</div>
</TableCell>
)}
<TableCell>
<ClientDate date={est.fechaEmision} />
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(est.importeEjecutado)}
</TableCell>
<TableCell className="text-right font-mono font-medium">
{formatCurrency(est.importeNeto)}
</TableCell>
<TableCell>
<Badge className={ESTADO_ESTIMACION_COLORS[est.estado]}>
{ESTADO_ESTIMACION_LABELS[est.estado]}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/obras/${est.presupuesto.obra.id}/estimaciones/${est.id}`}>
<Eye className="mr-2 h-4 w-4" />
Ver Detalle
</Link>
</DropdownMenuItem>
{est.estado === "BORRADOR" && (
<DropdownMenuItem asChild>
<Link href={`/obras/${est.presupuesto.obra.id}/estimaciones/${est.id}/editar`}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Link>
</DropdownMenuItem>
)}
{getNextActions(est.estado).length > 0 && (
<>
<DropdownMenuSeparator />
{getNextActions(est.estado).map((action) => (
<DropdownMenuItem
key={action.estado}
onClick={() => {
setActionId(est.id);
setActionType(action.estado);
}}
>
{action.icon}
{action.label}
</DropdownMenuItem>
))}
</>
)}
{["BORRADOR", "RECHAZADA"].includes(est.estado) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => setDeleteId(est.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Delete Dialog */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar Estimación</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. La estimación será eliminada permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Action Dialog */}
<AlertDialog open={!!actionId} onOpenChange={() => setActionId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{actionType === "RECHAZADA" ? "Rechazar Estimación" : "Cambiar Estado"}
</AlertDialogTitle>
<AlertDialogDescription>
{actionType === "RECHAZADA"
? "¿Estás seguro de rechazar esta estimación?"
: `¿Deseas cambiar el estado a "${ESTADO_ESTIMACION_LABELS[actionType]}"?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleChangeEstado}>
Confirmar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { EstimacionesList } from "./estimaciones-list";
export { EstimacionForm } from "./estimacion-form";
export { EstimacionDetail } from "./estimacion-detail";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { Gantt, Task, ViewMode } from "gantt-task-react"; import { Gantt, Task, ViewMode } from "gantt-task-react";
import "gantt-task-react/dist/index.css"; import "gantt-task-react/dist/index.css";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -19,8 +19,9 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Calendar, ZoomIn, ZoomOut, ListTree } from "lucide-react"; import { Calendar, ZoomIn, ZoomOut, ListTree, Loader2, RefreshCw } from "lucide-react";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { toast } from "@/hooks/use-toast";
interface Fase { interface Fase {
id: string; id: string;
@@ -52,6 +53,25 @@ interface Props {
onTaskUpdate?: (taskId: string, start: Date, end: Date) => void; onTaskUpdate?: (taskId: string, start: Date, end: Date) => void;
} }
interface APIFaseData {
id: string;
nombre: string;
orden: number;
fechaInicio: string | null;
fechaFin: string | null;
porcentajeAvance: number;
tareas: {
id: string;
nombre: string;
estado: string;
prioridad: number;
fechaInicio: string | null;
fechaFin: string | null;
porcentajeAvance: number;
asignado: string | null;
}[];
}
const ESTADO_COLORS: Record<string, string> = { const ESTADO_COLORS: Record<string, string> = {
PENDIENTE: "#9CA3AF", PENDIENTE: "#9CA3AF",
EN_PROGRESO: "#3B82F6", EN_PROGRESO: "#3B82F6",
@@ -64,11 +84,60 @@ export function DiagramaGantt({
obraNombre, obraNombre,
fechaInicioObra, fechaInicioObra,
fechaFinObra, fechaFinObra,
fases, fases: initialFases,
onTaskUpdate, onTaskUpdate,
}: Props) { }: Props) {
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Week); const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Week);
const [showTaskList, setShowTaskList] = useState(true); const [showTaskList, setShowTaskList] = useState(true);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [fases, setFases] = useState<Fase[]>(initialFases);
const [apiFases, setApiFases] = useState<APIFaseData[]>([]);
// Fetch data from API for complete dates
useEffect(() => {
fetchProgramacion();
}, [obraId]);
const fetchProgramacion = async () => {
try {
setLoading(true);
const response = await fetch("/api/obras/" + obraId + "/programacion");
if (response.ok) {
const data = await response.json();
setApiFases(data.fases || []);
}
} catch (error) {
console.error("Error fetching programacion:", error);
} finally {
setLoading(false);
}
};
// Use API data if available, otherwise fall back to props
const effectiveFases = useMemo(() => {
if (apiFases.length > 0) {
return apiFases.map((f) => ({
id: f.id,
nombre: f.nombre,
descripcion: null,
orden: f.orden,
fechaInicio: f.fechaInicio,
fechaFin: f.fechaFin,
porcentajeAvance: f.porcentajeAvance,
tareas: f.tareas.map((t) => ({
id: t.id,
nombre: t.nombre,
descripcion: null,
estado: t.estado,
fechaInicio: t.fechaInicio,
fechaFin: t.fechaFin,
porcentajeAvance: t.porcentajeAvance,
})),
}));
}
return fases;
}, [apiFases, fases]);
// Convertir fases y tareas a formato del Gantt // Convertir fases y tareas a formato del Gantt
const tasks: Task[] = useMemo(() => { const tasks: Task[] = useMemo(() => {
@@ -84,8 +153,8 @@ export function DiagramaGantt({
name: obraNombre, name: obraNombre,
id: `obra-${obraId}`, id: `obra-${obraId}`,
type: "project", type: "project",
progress: fases.length > 0 progress: effectiveFases.length > 0
? fases.reduce((acc, f) => acc + f.porcentajeAvance, 0) / fases.length ? effectiveFases.reduce((acc, f) => acc + f.porcentajeAvance, 0) / effectiveFases.length
: 0, : 0,
hideChildren: false, hideChildren: false,
styles: { styles: {
@@ -97,7 +166,7 @@ export function DiagramaGantt({
}); });
// Agregar fases y tareas // Agregar fases y tareas
fases.forEach((fase) => { effectiveFases.forEach((fase) => {
const faseStart = fase.fechaInicio const faseStart = fase.fechaInicio
? new Date(fase.fechaInicio) ? new Date(fase.fechaInicio)
: defaultStart; : defaultStart;
@@ -171,14 +240,105 @@ export function DiagramaGantt({
}); });
return result; return result;
}, [obraId, obraNombre, fechaInicioObra, fechaFinObra, fases]); }, [obraId, obraNombre, fechaInicioObra, fechaFinObra, effectiveFases]);
// Manejar cambios de fecha en tareas // Manejar cambios de fecha en tareas
const handleTaskChange = (task: Task) => { const handleTaskChange = async (task: Task) => {
if (onTaskUpdate && task.id.startsWith("tarea-")) { // Solo permitir cambios en tareas, no en fases o proyecto
if (!task.id.startsWith("tarea-")) {
toast({
title: "No permitido",
description: "Solo puede modificar las fechas de las tareas",
variant: "destructive",
});
return false;
}
const tareaId = task.id.replace("tarea-", ""); const tareaId = task.id.replace("tarea-", "");
setSaving(true);
try {
const response = await fetch("/api/obras/" + obraId + "/programacion", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tareaId,
fechaInicio: task.start.toISOString(),
fechaFin: task.end.toISOString(),
}),
});
if (!response.ok) {
throw new Error("Error al actualizar");
}
toast({
title: "Tarea actualizada",
description: "Las fechas han sido guardadas",
});
// Llamar callback si existe
if (onTaskUpdate) {
onTaskUpdate(tareaId, task.start, task.end); onTaskUpdate(tareaId, task.start, task.end);
} }
// Refrescar datos
fetchProgramacion();
return true;
} catch (error) {
toast({
title: "Error",
description: "No se pudieron guardar los cambios",
variant: "destructive",
});
fetchProgramacion(); // Revertir cambios visuales
return false;
} finally {
setSaving(false);
}
};
// Manejar cambios de progreso
const handleProgressChange = async (task: Task) => {
if (!task.id.startsWith("tarea-")) {
return false;
}
const tareaId = task.id.replace("tarea-", "");
setSaving(true);
try {
const response = await fetch("/api/obras/" + obraId + "/programacion", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tareaId,
porcentajeAvance: task.progress,
}),
});
if (!response.ok) {
throw new Error("Error al actualizar");
}
toast({
title: "Avance actualizado",
description: "Progreso: " + Math.round(task.progress) + "%",
});
fetchProgramacion();
return true;
} catch (error) {
toast({
title: "Error",
description: "No se pudo actualizar el avance",
variant: "destructive",
});
fetchProgramacion();
return false;
} finally {
setSaving(false);
}
}; };
// Manejar doble click para expandir/contraer // Manejar doble click para expandir/contraer
@@ -188,23 +348,34 @@ export function DiagramaGantt({
// Calcular estadísticas // Calcular estadísticas
const stats = useMemo(() => { const stats = useMemo(() => {
const totalTareas = fases.reduce((acc, f) => acc + f.tareas.length, 0); const totalTareas = effectiveFases.reduce((acc, f) => acc + f.tareas.length, 0);
const completadas = fases.reduce( const completadas = effectiveFases.reduce(
(acc, f) => acc + f.tareas.filter((t) => t.estado === "COMPLETADA").length, (acc, f) => acc + f.tareas.filter((t) => t.estado === "COMPLETADA").length,
0 0
); );
const enProgreso = fases.reduce( const enProgreso = effectiveFases.reduce(
(acc, f) => acc + f.tareas.filter((t) => t.estado === "EN_PROGRESO").length, (acc, f) => acc + f.tareas.filter((t) => t.estado === "EN_PROGRESO").length,
0 0
); );
const bloqueadas = fases.reduce( const bloqueadas = effectiveFases.reduce(
(acc, f) => acc + f.tareas.filter((t) => t.estado === "BLOQUEADA").length, (acc, f) => acc + f.tareas.filter((t) => t.estado === "BLOQUEADA").length,
0 0
); );
return { totalTareas, completadas, enProgreso, bloqueadas }; return { totalTareas, completadas, enProgreso, bloqueadas };
}, [fases]); }, [effectiveFases]);
if (fases.length === 0) { if (loading) {
return (
<Card>
<CardContent className="py-12 text-center">
<Loader2 className="h-8 w-8 mx-auto animate-spin text-muted-foreground mb-4" />
<p className="text-muted-foreground">Cargando programacion...</p>
</CardContent>
</Card>
);
}
if (effectiveFases.length === 0) {
return ( return (
<Card> <Card>
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
@@ -267,6 +438,15 @@ export function DiagramaGantt({
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchProgramacion}
disabled={saving}
>
<RefreshCw className={`h-4 w-4 mr-2 ${saving ? "animate-spin" : ""}`} />
Actualizar
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -283,10 +463,10 @@ export function DiagramaGantt({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={ViewMode.Day}>Día</SelectItem> <SelectItem value={ViewMode.Day}>Dia</SelectItem>
<SelectItem value={ViewMode.Week}>Semana</SelectItem> <SelectItem value={ViewMode.Week}>Semana</SelectItem>
<SelectItem value={ViewMode.Month}>Mes</SelectItem> <SelectItem value={ViewMode.Month}>Mes</SelectItem>
<SelectItem value={ViewMode.Year}>Año</SelectItem> <SelectItem value={ViewMode.Year}>Ano</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -298,6 +478,7 @@ export function DiagramaGantt({
tasks={tasks} tasks={tasks}
viewMode={viewMode} viewMode={viewMode}
onDateChange={handleTaskChange} onDateChange={handleTaskChange}
onProgressChange={handleProgressChange}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
listCellWidth={showTaskList ? "155px" : ""} listCellWidth={showTaskList ? "155px" : ""}
columnWidth={ columnWidth={
@@ -322,9 +503,9 @@ export function DiagramaGantt({
</CardContent> </CardContent>
</Card> </Card>
{/* Leyenda */} {/* Leyenda e Instrucciones */}
<Card> <Card>
<CardContent className="py-3"> <CardContent className="py-3 space-y-3">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<span className="text-sm font-medium">Leyenda:</span> <span className="text-sm font-medium">Leyenda:</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -352,6 +533,9 @@ export function DiagramaGantt({
<span className="text-sm">Proyecto</span> <span className="text-sm">Proyecto</span>
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground border-t pt-2">
<p><strong>Instrucciones:</strong> Arrastre las barras de tareas horizontalmente para cambiar fechas. Arrastre el borde derecho de la barra de progreso para actualizar el avance. Los cambios se guardan automaticamente.</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -0,0 +1,242 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { toast } from "@/hooks/use-toast";
import { Loader2, TrendingUp, AlertTriangle } from "lucide-react";
interface ActualizacionMasivaProps {
trigger?: React.ReactNode;
tipo?: "MATERIAL" | "MANO_OBRA" | "EQUIPO";
ids?: string[];
onComplete?: () => void;
}
export function ActualizacionMasiva({
trigger,
tipo: tipoInicial,
ids,
onComplete,
}: ActualizacionMasivaProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [tipo, setTipo] = useState<string>(tipoInicial || "");
const [metodo, setMetodo] = useState<string>("PORCENTAJE");
const [valor, setValor] = useState<string>("");
const [actualizarAPUs, setActualizarAPUs] = useState(true);
const handleSubmit = async () => {
if (!tipo) {
toast({
title: "Error",
description: "Seleccione el tipo de elemento a actualizar",
variant: "destructive",
});
return;
}
const valorNumerico = parseFloat(valor);
if (isNaN(valorNumerico)) {
toast({
title: "Error",
description: "Ingrese un valor válido",
variant: "destructive",
});
return;
}
if (metodo === "PORCENTAJE" && (valorNumerico < -100 || valorNumerico > 1000)) {
toast({
title: "Error",
description: "El porcentaje debe estar entre -100% y 1000%",
variant: "destructive",
});
return;
}
setLoading(true);
try {
const response = await fetch("/api/precios/actualizar-masivo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tipo,
metodo,
valor: valorNumerico,
ids: ids || undefined,
actualizarAPUs,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al actualizar");
}
const result = await response.json();
toast({
title: "Actualización completada",
description: result.mensaje,
});
setOpen(false);
setValor("");
onComplete?.();
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al actualizar",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const getTipoLabel = (t: string) => {
switch (t) {
case "MATERIAL":
return "Materiales";
case "MANO_OBRA":
return "Mano de Obra";
case "EQUIPO":
return "Equipos";
default:
return t;
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline">
<TrendingUp className="mr-2 h-4 w-4" />
Actualizar Precios
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Actualización Masiva de Precios</DialogTitle>
<DialogDescription>
{ids && ids.length > 0
? `Actualizar ${ids.length} elementos seleccionados`
: "Actualizar todos los elementos del catálogo"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{!tipoInicial && (
<div className="space-y-2">
<Label>Tipo de Elemento</Label>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MATERIAL">Materiales</SelectItem>
<SelectItem value="MANO_OBRA">Mano de Obra</SelectItem>
<SelectItem value="EQUIPO">Equipos</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Método de Actualización</Label>
<Select value={metodo} onValueChange={setMetodo}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PORCENTAJE">Por Porcentaje</SelectItem>
<SelectItem value="VALOR_FIJO">Valor Fijo</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
{metodo === "PORCENTAJE"
? "Porcentaje de Ajuste (%)"
: "Nuevo Precio Unitario"}
</Label>
<Input
type="number"
placeholder={metodo === "PORCENTAJE" ? "Ej: 5 para +5%" : "Ej: 150.00"}
value={valor}
onChange={(e) => setValor(e.target.value)}
step={metodo === "PORCENTAJE" ? "0.1" : "0.01"}
/>
{metodo === "PORCENTAJE" && (
<p className="text-xs text-muted-foreground">
Use valores negativos para reducir precios (ej: -10 para -10%)
</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label>Actualizar APUs</Label>
<p className="text-xs text-muted-foreground">
Recalcular precios unitarios de APUs que usan estos elementos
</p>
</div>
<Switch
checked={actualizarAPUs}
onCheckedChange={setActualizarAPUs}
/>
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-800">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5" />
<div className="text-sm">
<p className="font-medium">Advertencia</p>
<p className="text-xs mt-1">
Esta acción modificará los precios de forma permanente.
{actualizarAPUs &&
" Los APUs también serán recalculados automáticamente."}
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={loading}
>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={loading || !tipo || !valor}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Actualizar Precios
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { ActualizacionMasiva } from "./actualizacion-masiva";

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -21,6 +22,9 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { import {
Package, Package,
@@ -30,6 +34,7 @@ import {
FileSpreadsheet, FileSpreadsheet,
AlertTriangle, AlertTriangle,
Download, Download,
ShoppingCart,
} from "lucide-react"; } from "lucide-react";
import { UNIDAD_MEDIDA_LABELS } from "@/types"; import { UNIDAD_MEDIDA_LABELS } from "@/types";
import { UnidadMedida, TipoInsumoAPU } from "@prisma/client"; import { UnidadMedida, TipoInsumoAPU } from "@prisma/client";
@@ -73,18 +78,25 @@ interface ExplosionData {
interface ExplosionInsumosProps { interface ExplosionInsumosProps {
presupuestoId: string; presupuestoId: string;
presupuestoNombre: string; presupuestoNombre: string;
obraId?: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;
} }
export function ExplosionInsumos({ export function ExplosionInsumos({
presupuestoId, presupuestoId,
presupuestoNombre, presupuestoNombre,
obraId,
trigger, trigger,
}: ExplosionInsumosProps) { }: ExplosionInsumosProps) {
const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<ExplosionData | null>(null); const [data, setData] = useState<ExplosionData | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedMaterials, setSelectedMaterials] = useState<Set<number>>(new Set());
const [showOrderDialog, setShowOrderDialog] = useState(false);
const [creatingOrder, setCreatingOrder] = useState(false);
const [proveedorNombre, setProveedorNombre] = useState("");
useEffect(() => { useEffect(() => {
if (open && !data) { if (open && !data) {
@@ -218,6 +230,103 @@ export function ExplosionInsumos({
}); });
}; };
const toggleMaterialSelection = (index: number) => {
setSelectedMaterials((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const selectAllMaterials = () => {
if (!data) return;
const allIndexes = data.explosion.materiales.map((_, i) => i);
setSelectedMaterials(new Set(allIndexes));
};
const deselectAllMaterials = () => {
setSelectedMaterials(new Set());
};
const handleCreateOrder = async () => {
if (!data || !obraId || selectedMaterials.size === 0) return;
if (!proveedorNombre.trim()) {
toast({
title: "Error",
description: "El nombre del proveedor es requerido",
variant: "destructive",
});
return;
}
setCreatingOrder(true);
try {
const materialesSeleccionados = Array.from(selectedMaterials).map(
(index) => data.explosion.materiales[index]
);
const items = materialesSeleccionados.map((m) => ({
codigo: m.codigo || null,
descripcion: m.descripcion,
unidad: m.unidad,
cantidad: m.cantidadTotal,
precioUnitario: m.precioUnitario,
descuento: 0,
materialId: null,
}));
const response = await fetch("/api/ordenes-compra", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
obraId,
proveedorNombre: proveedorNombre.trim(),
prioridad: "NORMAL",
notas: `Orden generada desde explosión de insumos - ${presupuestoNombre}`,
items,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || "Error al crear orden");
}
const orden = await response.json();
toast({
title: "Orden de compra creada",
description: `Orden ${orden.numero} creada con ${items.length} materiales`,
});
setShowOrderDialog(false);
setSelectedMaterials(new Set());
setProveedorNombre("");
setOpen(false);
router.refresh();
} catch (err) {
toast({
title: "Error",
description: err instanceof Error ? err.message : "Error al crear orden",
variant: "destructive",
});
} finally {
setCreatingOrder(false);
}
};
const selectedTotal = data
? Array.from(selectedMaterials).reduce(
(sum, index) => sum + data.explosion.materiales[index].importeTotal,
0
)
: 0;
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -345,12 +454,47 @@ export function ExplosionInsumos({
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="materiales" className="mt-4"> <TabsContent value="materiales" className="mt-4 space-y-4">
<InsumoTable {obraId && data.explosion.materiales.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllMaterials}
>
Seleccionar todos
</Button>
<Button
variant="outline"
size="sm"
onClick={deselectAllMaterials}
>
Deseleccionar
</Button>
{selectedMaterials.size > 0 && (
<span className="text-sm text-muted-foreground">
{selectedMaterials.size} seleccionados ({formatCurrency(selectedTotal)})
</span>
)}
</div>
<Button
onClick={() => setShowOrderDialog(true)}
disabled={selectedMaterials.size === 0}
>
<ShoppingCart className="mr-2 h-4 w-4" />
Crear Orden de Compra
</Button>
</div>
)}
<InsumoTableWithSelection
insumos={data.explosion.materiales} insumos={data.explosion.materiales}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
formatQuantity={formatQuantity} formatQuantity={formatQuantity}
emptyMessage="No hay materiales en las partidas con APU" emptyMessage="No hay materiales en las partidas con APU"
selectedItems={selectedMaterials}
onToggleSelection={toggleMaterialSelection}
showSelection={!!obraId}
/> />
</TabsContent> </TabsContent>
@@ -376,6 +520,43 @@ export function ExplosionInsumos({
</div> </div>
) : null} ) : null}
</DialogContent> </DialogContent>
{/* Order Creation Dialog */}
<Dialog open={showOrderDialog} onOpenChange={setShowOrderDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Crear Orden de Compra</DialogTitle>
<DialogDescription>
Se creará una orden con {selectedMaterials.size} materiales seleccionados
por un total de {formatCurrency(selectedTotal)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="proveedor">Nombre del Proveedor *</Label>
<Input
id="proveedor"
placeholder="Ingrese el nombre del proveedor"
value={proveedorNombre}
onChange={(e) => setProveedorNombre(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowOrderDialog(false)}
disabled={creatingOrder}
>
Cancelar
</Button>
<Button onClick={handleCreateOrder} disabled={creatingOrder}>
{creatingOrder && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Crear Orden
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog> </Dialog>
); );
} }
@@ -455,3 +636,94 @@ function InsumoTable({
</div> </div>
); );
} }
interface InsumoTableWithSelectionProps extends InsumoTableProps {
selectedItems: Set<number>;
onToggleSelection: (index: number) => void;
showSelection: boolean;
}
function InsumoTableWithSelection({
insumos,
formatCurrency,
formatQuantity,
emptyMessage,
selectedItems,
onToggleSelection,
showSelection,
}: InsumoTableWithSelectionProps) {
if (insumos.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-slate-500">
{emptyMessage}
</div>
);
}
const total = insumos.reduce((sum, i) => sum + i.importeTotal, 0);
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{showSelection && <TableHead className="w-[50px]"></TableHead>}
<TableHead className="w-[80px]">Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="w-[80px]">Unidad</TableHead>
<TableHead className="w-[100px] text-right">Cantidad</TableHead>
<TableHead className="w-[100px] text-right">P.U.</TableHead>
<TableHead className="w-[120px] text-right">Importe</TableHead>
<TableHead className="w-[80px] text-center">Partidas</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insumos.map((insumo, index) => (
<TableRow
key={index}
className={selectedItems.has(index) ? "bg-blue-50" : ""}
>
{showSelection && (
<TableCell>
<Checkbox
checked={selectedItems.has(index)}
onCheckedChange={() => onToggleSelection(index)}
/>
</TableCell>
)}
<TableCell className="font-mono text-sm">
{insumo.codigo || "-"}
</TableCell>
<TableCell>{insumo.descripcion}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{UNIDAD_MEDIDA_LABELS[insumo.unidad]}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
{formatQuantity(insumo.cantidadTotal)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(insumo.precioUnitario)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(insumo.importeTotal)}
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{insumo.partidasCount}</Badge>
</TableCell>
</TableRow>
))}
<TableRow className="bg-slate-50 font-semibold">
{showSelection && <TableCell></TableCell>}
<TableCell colSpan={5}>Total</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(total)}
</TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,372 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/hooks/use-toast";
import {
Loader2,
Calendar,
ZoomIn,
ZoomOut,
RotateCcw,
GanttChartSquare,
} from "lucide-react";
import { Gantt, ViewMode, Task } from "gantt-task-react";
import "gantt-task-react/dist/index.css";
interface GanttChartProps {
obraId: string;
}
interface GanttData {
obra: {
id: string;
nombre: string;
fechaInicio: string | null;
fechaFinPrevista: string | null;
porcentajeAvance: number;
};
fases: {
id: string;
nombre: string;
orden: number;
fechaInicio: string | null;
fechaFin: string | null;
porcentajeAvance: number;
tareas: {
id: string;
nombre: string;
estado: string;
prioridad: number;
fechaInicio: string | null;
fechaFin: string | null;
porcentajeAvance: number;
asignado: string | null;
}[];
}[];
ganttTasks: {
id: string;
type: "project" | "task";
name: string;
start: string;
end: string;
progress: number;
project?: string;
dependencies?: string[];
styles?: { backgroundColor?: string; progressColor?: string };
hideChildren?: boolean;
displayOrder: number;
}[];
}
const VIEW_MODES = [
{ value: ViewMode.Day, label: "Día" },
{ value: ViewMode.Week, label: "Semana" },
{ value: ViewMode.Month, label: "Mes" },
{ value: ViewMode.Year, label: "Año" },
];
export function GanttChart({ obraId }: GanttChartProps) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<GanttData | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Week);
const [columnWidth, setColumnWidth] = useState(65);
useEffect(() => {
fetchData();
}, [obraId]);
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch("/api/obras/" + obraId + "/programacion");
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 de programacion",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const tasks: Task[] = useMemo(() => {
if (!data?.ganttTasks) return [];
return data.ganttTasks.map((t) => ({
id: t.id,
type: t.type,
name: t.name,
start: new Date(t.start),
end: new Date(t.end),
progress: t.progress,
project: t.project,
dependencies: t.dependencies,
styles: t.styles,
hideChildren: t.hideChildren,
displayOrder: t.displayOrder,
isDisabled: false,
}));
}, [data]);
const handleTaskChange = async (task: Task) => {
if (!task.id.startsWith("tarea-")) {
return;
}
const tareaId = task.id.replace("tarea-", "");
try {
const response = await fetch("/api/obras/" + obraId + "/programacion", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tareaId,
fechaInicio: task.start.toISOString(),
fechaFin: task.end.toISOString(),
}),
});
if (!response.ok) {
throw new Error("Error al actualizar");
}
toast({
title: "Tarea actualizada",
description: "Las fechas de la tarea han sido actualizadas",
});
fetchData();
} catch (error) {
toast({
title: "Error",
description: "No se pudo actualizar la tarea",
variant: "destructive",
});
fetchData();
}
};
const handleProgressChange = async (task: Task) => {
if (!task.id.startsWith("tarea-")) {
return;
}
const tareaId = task.id.replace("tarea-", "");
try {
const response = await fetch("/api/obras/" + obraId + "/programacion", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tareaId,
porcentajeAvance: task.progress,
}),
});
if (!response.ok) {
throw new Error("Error al actualizar");
}
toast({
title: "Avance actualizado",
description: "Avance: " + task.progress.toFixed(0) + "%",
});
fetchData();
} catch (error) {
toast({
title: "Error",
description: "No se pudo actualizar el avance",
variant: "destructive",
});
fetchData();
}
};
const handleExpanderClick = (task: Task) => {
setData((prev) => {
if (!prev) return prev;
return {
...prev,
ganttTasks: prev.ganttTasks.map((t) => {
if (t.id === task.id) {
return { ...t, hideChildren: !t.hideChildren };
}
return t;
}),
};
});
};
const zoomIn = () => {
setColumnWidth((prev) => Math.min(prev + 15, 150));
};
const zoomOut = () => {
setColumnWidth((prev) => Math.max(prev - 15, 40));
};
const goToToday = () => {
fetchData();
};
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (!data || tasks.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GanttChartSquare className="h-5 w-5" />
Programacion de Obra
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Calendar className="h-12 w-12 mb-4" />
<p className="text-lg font-medium mb-2">Sin programacion</p>
<p className="text-sm">
Agregue fases y tareas a la obra para visualizar el diagrama de Gantt
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<GanttChartSquare className="h-5 w-5" />
Programacion de Obra
</CardTitle>
<div className="flex items-center gap-2">
<Select
value={viewMode}
onValueChange={(v) => setViewMode(v as ViewMode)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VIEW_MODES.map((mode) => (
<SelectItem key={mode.value} value={mode.value}>
{mode.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center border rounded-md">
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
className="h-8 w-8"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
className="h-8 w-8"
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={goToToday}>
<RotateCcw className="h-4 w-4 mr-1" />
Hoy
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 mb-4 text-sm">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-blue-700" />
<span>Obra</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-cyan-600" />
<span>Fase</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-slate-500" />
<span>Tarea</span>
</div>
<div className="flex items-center gap-1 ml-4">
<div className="w-8 h-3 rounded bg-slate-300" />
<span>= Progreso</span>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-4">
{data.fases.map((fase) => (
<Badge key={fase.id} variant="outline" className="flex items-center gap-2">
{fase.nombre}
<span className="text-xs bg-slate-100 px-1 rounded">
{fase.porcentajeAvance.toFixed(0)}%
</span>
<span className="text-xs text-muted-foreground">
({fase.tareas.length} tareas)
</span>
</Badge>
))}
</div>
<div className="gantt-wrapper overflow-x-auto border rounded-lg">
<Gantt
tasks={tasks}
viewMode={viewMode}
onDateChange={handleTaskChange}
onProgressChange={handleProgressChange}
onExpanderClick={handleExpanderClick}
listCellWidth="155px"
columnWidth={columnWidth}
ganttHeight={400}
locale="es"
barCornerRadius={4}
barFill={60}
todayColor="rgba(59, 130, 246, 0.1)"
arrowColor="#94a3b8"
fontSize="12px"
/>
</div>
<div className="mt-4 text-xs text-muted-foreground">
<p>
* Arrastre las barras horizontalmente para cambiar fechas.
Arrastre el borde derecho de las barras de progreso para actualizar el avance.
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1 @@
export { GanttChart } from "./gantt-chart";

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

178
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,178 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -1,10 +1,11 @@
"use client"; "use client"
import * as React from "react"; import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root; import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
@@ -18,8 +19,8 @@ const TabsList = React.forwardRef<
)} )}
{...props} {...props}
/> />
)); ))
TabsList.displayName = TabsPrimitive.List.displayName; TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>, React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -33,8 +34,8 @@ const TabsTrigger = React.forwardRef<
)} )}
{...props} {...props}
/> />
)); ))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef< const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Content>,
@@ -48,7 +49,7 @@ const TabsContent = React.forwardRef<
)} )}
{...props} {...props}
/> />
)); ))
TabsContent.displayName = TabsPrimitive.Content.displayName; TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }; export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,141 @@
import { z } from "zod";
// Enum schemas
export const estadoEstimacionEnum = z.enum([
"BORRADOR",
"ENVIADA",
"EN_REVISION",
"APROBADA",
"RECHAZADA",
"PAGADA",
]);
// Partida de estimación
export const estimacionPartidaSchema = z.object({
partidaId: z.string().min(1, "La partida es requerida"),
cantidadEstimacion: z.number().min(0, "La cantidad debe ser positiva"),
notas: z.string().optional(),
});
// Crear estimación
export const crearEstimacionSchema = z.object({
presupuestoId: z.string().min(1, "El presupuesto es requerido"),
periodo: z.string().min(1, "El periodo es requerido"),
fechaInicio: z.string().or(z.date()),
fechaFin: z.string().or(z.date()),
porcentajeRetencion: z.number().min(0).max(100).default(5),
porcentajeIVA: z.number().min(0).max(100).default(16),
amortizacion: z.number().min(0).default(0),
deduccionesVarias: z.number().min(0).default(0),
observaciones: z.string().optional(),
partidas: z.array(estimacionPartidaSchema).min(1, "Debe incluir al menos una partida"),
});
// Actualizar estimación
export const actualizarEstimacionSchema = z.object({
periodo: z.string().min(1).optional(),
fechaInicio: z.string().or(z.date()).optional(),
fechaFin: z.string().or(z.date()).optional(),
porcentajeRetencion: z.number().min(0).max(100).optional(),
porcentajeIVA: z.number().min(0).max(100).optional(),
amortizacion: z.number().min(0).optional(),
deduccionesVarias: z.number().min(0).optional(),
observaciones: z.string().optional(),
partidas: z.array(estimacionPartidaSchema).optional(),
});
// Cambiar estado
export const cambiarEstadoEstimacionSchema = z.object({
estado: estadoEstimacionEnum,
motivoRechazo: z.string().optional(),
});
// Types inferidos
export type EstadoEstimacion = z.infer<typeof estadoEstimacionEnum>;
export type EstimacionPartidaInput = z.infer<typeof estimacionPartidaSchema>;
export type CrearEstimacionInput = z.infer<typeof crearEstimacionSchema>;
export type ActualizarEstimacionInput = z.infer<typeof actualizarEstimacionSchema>;
export type CambiarEstadoEstimacionInput = z.infer<typeof cambiarEstadoEstimacionSchema>;
// Labels para estados
export const ESTADO_ESTIMACION_LABELS: Record<string, string> = {
BORRADOR: "Borrador",
ENVIADA: "Enviada",
EN_REVISION: "En Revisión",
APROBADA: "Aprobada",
RECHAZADA: "Rechazada",
PAGADA: "Pagada",
};
export const ESTADO_ESTIMACION_COLORS: Record<string, string> = {
BORRADOR: "bg-gray-100 text-gray-800",
ENVIADA: "bg-blue-100 text-blue-800",
EN_REVISION: "bg-yellow-100 text-yellow-800",
APROBADA: "bg-green-100 text-green-800",
RECHAZADA: "bg-red-100 text-red-800",
PAGADA: "bg-purple-100 text-purple-800",
};
// Función helper para calcular totales de estimación
export function calcularTotalesEstimacion(
partidas: Array<{
cantidadEstimacion: number;
precioUnitario: number;
cantidadAnterior: number;
cantidadContrato: number;
}>,
config: {
porcentajeRetencion: number;
porcentajeIVA: number;
amortizacion: number;
deduccionesVarias: number;
}
): {
importeEjecutado: number;
importeAnterior: number;
importeAcumulado: number;
retencion: number;
subtotal: number;
iva: number;
total: number;
importeNeto: number;
} {
// Calcular importes por partida
const importeEjecutado = partidas.reduce(
(sum, p) => sum + p.cantidadEstimacion * p.precioUnitario,
0
);
const importeAnterior = partidas.reduce(
(sum, p) => sum + p.cantidadAnterior * p.precioUnitario,
0
);
const importeAcumulado = importeAnterior + importeEjecutado;
// Calcular retención
const retencion = importeEjecutado * (config.porcentajeRetencion / 100);
// Subtotal (importe ejecutado - retención - amortización - deducciones)
const subtotal = importeEjecutado - retencion - config.amortizacion - config.deduccionesVarias;
// IVA
const iva = subtotal * (config.porcentajeIVA / 100);
// Total con IVA
const total = subtotal + iva;
// Importe neto a pagar
const importeNeto = total;
return {
importeEjecutado,
importeAnterior,
importeAcumulado,
retencion,
subtotal,
iva,
total,
importeNeto,
};
}