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

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