From 09a29bb5c1dfd973a90f7737ae2a4e9604c23008 Mon Sep 17 00:00:00 2001 From: Mexus Date: Thu, 5 Feb 2026 23:10:29 +0000 Subject: [PATCH] feat: Add estimaciones, dashboard comparativo, bulk pricing, and enhanced Gantt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- prisma/schema.prisma | 102 ++- .../[estimacionId]/editar/page.tsx | 104 +++ .../[id]/estimaciones/[estimacionId]/page.tsx | 76 +++ .../obras/[id]/estimaciones/nueva/page.tsx | 82 +++ .../obras/[id]/obra-detail-client.tsx | 29 + src/app/(dashboard)/obras/[id]/page.tsx | 14 + .../(dashboard)/reportes/reportes-client.tsx | 290 +++++++- src/app/api/dashboard/comparativo/route.ts | 203 ++++++ src/app/api/estimaciones/[id]/route.ts | 438 ++++++++++++ src/app/api/estimaciones/route.ts | 263 +++++++ src/app/api/obras/[id]/programacion/route.ts | 300 ++++++++ .../api/precios/actualizar-masivo/route.ts | 320 +++++++++ src/components/apu/apu-list.tsx | 2 + .../dashboard/comparativo-dashboard.tsx | 403 +++++++++++ src/components/dashboard/index.ts | 1 + .../estimaciones/estimacion-detail.tsx | 644 ++++++++++++++++++ .../estimaciones/estimacion-form.tsx | 639 +++++++++++++++++ .../estimaciones/estimaciones-list.tsx | 416 +++++++++++ src/components/estimaciones/index.ts | 3 + src/components/gantt/diagrama-gantt.tsx | 226 +++++- .../precios/actualizacion-masiva.tsx | 242 +++++++ src/components/precios/index.ts | 1 + .../presupuesto/explosion-insumos.tsx | 276 +++++++- src/components/programacion/gantt-chart.tsx | 372 ++++++++++ src/components/programacion/index.ts | 1 + src/components/ui/checkbox.tsx | 30 + src/components/ui/form.tsx | 178 +++++ src/components/ui/tabs.tsx | 25 +- src/lib/validations/estimaciones.ts | 141 ++++ 29 files changed, 5771 insertions(+), 50 deletions(-) create mode 100644 src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/editar/page.tsx create mode 100644 src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/page.tsx create mode 100644 src/app/(dashboard)/obras/[id]/estimaciones/nueva/page.tsx create mode 100644 src/app/api/dashboard/comparativo/route.ts create mode 100644 src/app/api/estimaciones/[id]/route.ts create mode 100644 src/app/api/estimaciones/route.ts create mode 100644 src/app/api/obras/[id]/programacion/route.ts create mode 100644 src/app/api/precios/actualizar-masivo/route.ts create mode 100644 src/components/dashboard/comparativo-dashboard.tsx create mode 100644 src/components/dashboard/index.ts create mode 100644 src/components/estimaciones/estimacion-detail.tsx create mode 100644 src/components/estimaciones/estimacion-form.tsx create mode 100644 src/components/estimaciones/estimaciones-list.tsx create mode 100644 src/components/estimaciones/index.ts create mode 100644 src/components/precios/actualizacion-masiva.tsx create mode 100644 src/components/precios/index.ts create mode 100644 src/components/programacion/gantt-chart.tsx create mode 100644 src/components/programacion/index.ts create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/lib/validations/estimaciones.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 828825d..7f9d3e0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -341,7 +341,8 @@ model Presupuesto { updatedAt DateTime @updatedAt // Relations - partidas PartidaPresupuesto[] + partidas PartidaPresupuesto[] + estimaciones Estimacion[] @@index([obraId]) } @@ -363,8 +364,9 @@ model PartidaPresupuesto { updatedAt DateTime @updatedAt // Relations - gastos Gasto[] - avances AvancePartida[] + gastos Gasto[] + avances AvancePartida[] + estimacionPartidas EstimacionPartida[] @@index([presupuestoId]) @@index([apuId]) @@ -1108,6 +1110,100 @@ model ConfiguracionAPU { 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 ============== enum TipoImportacion { diff --git a/src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/editar/page.tsx b/src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/editar/page.tsx new file mode 100644 index 0000000..2360407 --- /dev/null +++ b/src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/editar/page.tsx @@ -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 = {}; + 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 ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/page.tsx b/src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/page.tsx new file mode 100644 index 0000000..0cb8f50 --- /dev/null +++ b/src/app/(dashboard)/obras/[id]/estimaciones/[estimacionId]/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/obras/[id]/estimaciones/nueva/page.tsx b/src/app/(dashboard)/obras/[id]/estimaciones/nueva/page.tsx new file mode 100644 index 0000000..c7cdecc --- /dev/null +++ b/src/app/(dashboard)/obras/[id]/estimaciones/nueva/page.tsx @@ -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 = {}; + 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 ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx index cfdacce..ad093fb 100644 --- a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx +++ b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx @@ -54,6 +54,7 @@ import { } from "@/components/pdf"; import { PartidasManager, ExplosionInsumos } from "@/components/presupuesto"; import { ControlAvancePartidas } from "@/components/avances"; +import { EstimacionesList } from "@/components/estimaciones"; // Componente de carga const LoadingSpinner = () => ( @@ -143,6 +144,17 @@ interface ObraDetailProps { precioUnitario: number; } | 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: { id: string; @@ -586,6 +598,7 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {

@@ -605,6 +618,7 @@ export function ObraDetailClient({ obra }: ObraDetailProps) { Partidas Control de Avance + Estimaciones + + ({ + ...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} + /> + diff --git a/src/app/(dashboard)/obras/[id]/page.tsx b/src/app/(dashboard)/obras/[id]/page.tsx index d37c248..a53b39b 100644 --- a/src/app/(dashboard)/obras/[id]/page.tsx +++ b/src/app/(dashboard)/obras/[id]/page.tsx @@ -37,6 +37,20 @@ async function getObra(id: string, empresaId: string) { }, 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" }, }, diff --git a/src/app/(dashboard)/reportes/reportes-client.tsx b/src/app/(dashboard)/reportes/reportes-client.tsx index 69b0830..3b4faba 100644 --- a/src/app/(dashboard)/reportes/reportes-client.tsx +++ b/src/app/(dashboard)/reportes/reportes-client.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; +import { pdf } from "@react-pdf/renderer"; import { Button } from "@/components/ui/button"; import { Card, @@ -15,7 +17,9 @@ import { SelectTrigger, SelectValue, } 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 { CATEGORIA_GASTO_LABELS, @@ -38,7 +42,208 @@ import { LineChart, Line, } 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; +}) => ( + + + {/* Header */} + + Reporte General de Obras + + Generado el {new Date().toLocaleDateString("es-MX", { + year: "numeric", + month: "long", + day: "numeric", + })} + + + + {/* Resumen */} + + Resumen Ejecutivo + + + Presupuesto Total + + ${totalPresupuesto.toLocaleString("es-MX", { minimumFractionDigits: 2 })} + + + + Total Gastado + + ${totalGastado.toLocaleString("es-MX", { minimumFractionDigits: 2 })} + + + + Variacion + = 0 ? pdfStyles.positive : pdfStyles.negative]}> + {variacion >= 0 ? "+" : ""}${variacion.toLocaleString("es-MX", { minimumFractionDigits: 2 })} + + + + % Ejecutado + + {totalPresupuesto > 0 ? ((totalGastado / totalPresupuesto) * 100).toFixed(1) : "0"}% + + + + + + {/* Tabla de Obras */} + + Detalle por Obra + + + Obra + Estado + Presupuesto + Gastado + Avance + + {data.obras.map((obra) => ( + + {obra.nombre} + {ESTADO_OBRA_LABELS[obra.estado]} + + ${obra.presupuestoTotal.toLocaleString("es-MX")} + + + ${obra.gastoTotal.toLocaleString("es-MX")} + + {obra.porcentajeAvance}% + + ))} + + + + {/* Gastos por Categoria */} + + Gastos por Categoria + + + Categoria + Total + + {data.gastosPorCategoria.map((g, index) => ( + + {CATEGORIA_GASTO_LABELS[g.categoria]} + + ${g.total.toLocaleString("es-MX", { minimumFractionDigits: 2 })} + + + ))} + + + + {/* Footer */} + + Sistema de Gestion de Obras - Reporte generado automaticamente + + + +); interface ReportesData { obras: { @@ -79,6 +284,7 @@ const COLORS = [ export function ReportesClient({ data }: { data: ReportesData }) { const [selectedObra, setSelectedObra] = useState("all"); + const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const totalPresupuesto = data.obras.reduce( (sum, o) => sum + o.presupuestoTotal, @@ -116,6 +322,41 @@ export function ReportesClient({ data }: { data: ReportesData }) { link.click(); }; + const exportToPDF = async () => { + setIsGeneratingPDF(true); + try { + const pdfBlob = await pdf( + + ).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 (

@@ -125,18 +366,37 @@ export function ReportesClient({ data }: { data: ReportesData }) { Analisis y exportacion de datos

-
- - -
+ + + + + Reporte General + + + + Presupuesto vs Ejecutado + + + + + {/* Export buttons for general reports */} +
+ + +
+ {/* Summary Cards */}
@@ -361,6 +621,12 @@ export function ReportesClient({ data }: { data: ReportesData }) {
+
+ + + + +
); } diff --git a/src/app/api/dashboard/comparativo/route.ts b/src/app/api/dashboard/comparativo/route.ts new file mode 100644 index 0000000..b983fd4 --- /dev/null +++ b/src/app/api/dashboard/comparativo/route.ts @@ -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 = { 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 } + ); + } +} diff --git a/src/app/api/estimaciones/[id]/route.ts b/src/app/api/estimaciones/[id]/route.ts new file mode 100644 index 0000000..4d2ecd9 --- /dev/null +++ b/src/app/api/estimaciones/[id]/route.ts @@ -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 = {}; + 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 = { + 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 = { + 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 } + ); + } +} diff --git a/src/app/api/estimaciones/route.ts b/src/app/api/estimaciones/route.ts new file mode 100644 index 0000000..c23b659 --- /dev/null +++ b/src/app/api/estimaciones/route.ts @@ -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 = { + 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 = {}; + 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 } + ); + } +} diff --git a/src/app/api/obras/[id]/programacion/route.ts b/src/app/api/obras/[id]/programacion/route.ts new file mode 100644 index 0000000..5c831e9 --- /dev/null +++ b/src/app/api/obras/[id]/programacion/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/precios/actualizar-masivo/route.ts b/src/app/api/precios/actualizar-masivo/route.ts new file mode 100644 index 0000000..a1e68d4 --- /dev/null +++ b/src/app/api/precios/actualizar-masivo/route.ts @@ -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 = { 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(); + 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 = { 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(); + 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 = { 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(); + 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, + }, + }); +} diff --git a/src/components/apu/apu-list.tsx b/src/components/apu/apu-list.tsx index 6a3b9d6..c65ed47 100644 --- a/src/components/apu/apu-list.tsx +++ b/src/components/apu/apu-list.tsx @@ -52,6 +52,7 @@ import { import { UNIDAD_MEDIDA_LABELS } from "@/types"; import { UnidadMedida } from "@prisma/client"; import { ImportadorCatalogo } from "@/components/importar"; +import { ActualizacionMasiva } from "@/components/precios"; interface APU { id: string; @@ -195,6 +196,7 @@ export function APUList({ apus: initialApus }: APUListProps) { )} + router.refresh()} /> + +
+
+

Estimación #{estimacion.numero}

+ + {ESTADO_ESTIMACION_LABELS[estado]} + +
+

+ {estimacion.presupuesto.obra.nombre} - {estimacion.presupuesto.nombre} +

+
+ +
+ + + + + + + + {estado === "BORRADOR" && ( + <> + + + + Editar + + + + openActionDialog( + "ENVIADA", + "Enviar Estimación", + "¿Desea enviar esta estimación para revisión?" + ) + } + > + + Enviar + + + + )} + {estado === "ENVIADA" && ( + <> + + openActionDialog( + "EN_REVISION", + "Poner en Revisión", + "¿Desea marcar esta estimación como en revisión?" + ) + } + > + + Poner en Revisión + + + openActionDialog( + "RECHAZADA", + "Rechazar Estimación", + "¿Está seguro de rechazar esta estimación?" + ) + } + className="text-red-600" + > + + Rechazar + + + )} + {estado === "EN_REVISION" && ( + <> + + openActionDialog( + "APROBADA", + "Aprobar Estimación", + "¿Desea aprobar esta estimación?" + ) + } + > + + Aprobar + + + openActionDialog( + "RECHAZADA", + "Rechazar Estimación", + "¿Está seguro de rechazar esta estimación?" + ) + } + className="text-red-600" + > + + Rechazar + + + )} + {estado === "APROBADA" && ( + + openActionDialog( + "PAGADA", + "Marcar como Pagada", + "¿Confirma que esta estimación ha sido pagada?" + ) + } + > + + Marcar como Pagada + + )} + {estado === "RECHAZADA" && ( + + openActionDialog( + "BORRADOR", + "Volver a Borrador", + "¿Desea regresar esta estimación a borrador para editarla?" + ) + } + > + + Volver a Borrador + + )} + {["BORRADOR", "RECHAZADA"].includes(estado) && ( + <> + + + openActionDialog( + "DELETE", + "Eliminar Estimación", + "Esta acción no se puede deshacer. ¿Está seguro de eliminar esta estimación?" + ) + } + className="text-red-600" + > + + Eliminar + + + )} + + +
+ + + {/* Info Cards */} +
+ + + Periodo + {estimacion.periodo} + + +

+ -{" "} + +

+
+
+ + + Importe Ejecutado + + {formatCurrency(estimacion.importeEjecutado)} + + + +

+ Acumulado: {formatCurrency(estimacion.importeAcumulado)} +

+
+
+ + + Deducciones + + {formatCurrency( + estimacion.retencion + estimacion.amortizacion + estimacion.deduccionesVarias + )} + + + +

+ Ret: {formatCurrency(estimacion.retencion)} +

+
+
+ + + Importe Neto + + {formatCurrency(estimacion.importeNeto)} + + + +

+ IVA: {formatCurrency(estimacion.iva)} +

+
+
+
+ + {/* Rejected reason */} + {estimacion.motivoRechazo && ( + + + Motivo de Rechazo + + +

{estimacion.motivoRechazo}

+
+
+ )} + + {/* Partidas Table */} + + + Desglose de Partidas + + {estimacion.partidas.length} partidas incluidas en esta estimación + + + +
+ + + + Código + Descripción + Unidad + Contrato + Anterior + Esta Est. + Acumulado + P.U. + Importe + Avance + + + + {estimacion.partidas.map((item) => ( + + + {item.partida.codigo} + + + {item.partida.descripcion} + + {UNIDAD_MEDIDA_LABELS[item.partida.unidad]} + + {item.cantidadContrato.toFixed(2)} + + + {item.cantidadAnterior.toFixed(2)} + + + {item.cantidadEstimacion.toFixed(2)} + + + {item.cantidadAcumulada.toFixed(2)} + + + {formatCurrency(item.precioUnitario)} + + + {formatCurrency(item.importeEstimacion)} + + +
+ + + {formatPercentage(item.porcentajeAcumulado)} + +
+
+
+ ))} +
+ + + + Subtotal Partidas: + + + {formatCurrency(estimacion.importeEjecutado)} + + + + +
+
+
+
+ + {/* Resumen de Totales */} + + + Resumen de Totales + + +
+
+ Importe Ejecutado: + {formatCurrency(estimacion.importeEjecutado)} +
+
+ (-) Retención ({estimacion.porcentajeRetencion}%): + {formatCurrency(estimacion.retencion)} +
+ {estimacion.amortizacion > 0 && ( +
+ (-) Amortización: + {formatCurrency(estimacion.amortizacion)} +
+ )} + {estimacion.deduccionesVarias > 0 && ( +
+ (-) Otras Deducciones: + {formatCurrency(estimacion.deduccionesVarias)} +
+ )} +
+ Subtotal: + {formatCurrency(estimacion.subtotal)} +
+
+ IVA ({estimacion.porcentajeIVA}%): + {formatCurrency(estimacion.iva)} +
+
+ Total a Pagar: + + {formatCurrency(estimacion.importeNeto)} + +
+
+
+
+ + {/* Observaciones */} + {estimacion.observaciones && ( + + + Observaciones + + +

{estimacion.observaciones}

+
+
+ )} + + {/* Fechas */} + + + Historial + + +
+
+

Fecha de Emisión

+

+ +

+
+ {estimacion.fechaEnvio && ( +
+

Fecha de Envío

+

+ +

+
+ )} + {estimacion.fechaAprobacion && ( +
+

Fecha de Aprobación

+

+ +

+
+ )} +
+
+
+ + {/* Action Dialog */} + !open && setActionDialog({ ...actionDialog, open: false })} + > + + + {actionDialog.title} + {actionDialog.description} + + + Cancelar + + {isLoading ? "Procesando..." : "Confirmar"} + + + + + + ); +} diff --git a/src/components/estimaciones/estimacion-form.tsx b/src/components/estimaciones/estimacion-form.tsx new file mode 100644 index 0000000..af09b5f --- /dev/null +++ b/src/components/estimaciones/estimacion-form.tsx @@ -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; + +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; + 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(() => { + 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({ + 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 ( +
+
+ + + +
+

+ {estimacionExistente + ? `Editar Estimación #${estimacionExistente.numero}` + : "Nueva Estimación"} +

+

+ {presupuesto.obra.nombre} - {presupuesto.nombre} +

+
+
+ +
+ + {/* Datos Generales */} + + + Datos de la Estimación + + + ( + + Periodo + + + + + + )} + /> + ( + + Fecha Inicio + + + + + + )} + /> + ( + + Fecha Fin + + + + + + )} + /> + + + + {/* Partidas */} + + + Partidas a Estimar + + Ingrese las cantidades ejecutadas en este periodo + + + +
+ + + + + p.seleccionada)} + onCheckedChange={(checked) => handleSelectAll(!!checked)} + /> + + Código + Descripción + Unidad + Contrato + Anterior + Esta Est. + Acumulado + Importe + Avance + + + + {partidasEstimacion.map((partida) => ( + + + + handleSelectPartida(partida.id, !!checked) + } + /> + + + {partida.codigo} + + + {partida.descripcion} + + {UNIDAD_MEDIDA_LABELS[partida.unidad]} + + {partida.cantidad.toFixed(2)} + + + {partida.cantidadAnterior.toFixed(2)} + + +
+ + handleCantidadChange(partida.id, e.target.value) + } + className="w-20 text-right h-8" + disabled={!partida.seleccionada} + /> + +
+
+ + {partida.cantidadAcumulada.toFixed(2)} + + + {formatCurrency(partida.importeEstimacion)} + + +
+ + + {formatPercentage(partida.porcentajeAcumulado)} + +
+
+
+ ))} +
+
+
+
+
+ + {/* Deducciones y Totales */} +
+ + + Deducciones + + + ( + + % Retención + + + + + + )} + /> + ( + + Amortización de Anticipo + + + + + + )} + /> + ( + + Otras Deducciones + + + + + + )} + /> + ( + + % IVA + + + + + + )} + /> + ( + + Observaciones + +