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:
@@ -341,7 +341,8 @@ model Presupuesto {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
partidas PartidaPresupuesto[]
|
partidas PartidaPresupuesto[]
|
||||||
|
estimaciones Estimacion[]
|
||||||
|
|
||||||
@@index([obraId])
|
@@index([obraId])
|
||||||
}
|
}
|
||||||
@@ -363,8 +364,9 @@ model PartidaPresupuesto {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/app/(dashboard)/obras/[id]/estimaciones/nueva/page.tsx
Normal file
82
src/app/(dashboard)/obras/[id]/estimaciones/nueva/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,18 +366,37 @@ 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">
|
|
||||||
<Button variant="outline" onClick={exportToCSV}>
|
|
||||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
||||||
Exportar CSV
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline">
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Exportar PDF
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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}>
|
||||||
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||||
|
Exportar CSV
|
||||||
|
</Button>
|
||||||
|
<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" />
|
||||||
|
)}
|
||||||
|
{isGeneratingPDF ? "Generando..." : "Exportar PDF"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
203
src/app/api/dashboard/comparativo/route.ts
Normal file
203
src/app/api/dashboard/comparativo/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
438
src/app/api/estimaciones/[id]/route.ts
Normal file
438
src/app/api/estimaciones/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/app/api/estimaciones/route.ts
Normal file
263
src/app/api/estimaciones/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/app/api/obras/[id]/programacion/route.ts
Normal file
300
src/app/api/obras/[id]/programacion/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
320
src/app/api/precios/actualizar-masivo/route.ts
Normal file
320
src/app/api/precios/actualizar-masivo/route.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
403
src/components/dashboard/comparativo-dashboard.tsx
Normal file
403
src/components/dashboard/comparativo-dashboard.tsx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
DollarSign,
|
||||||
|
Building2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { CATEGORIA_GASTO_LABELS, ESTADO_OBRA_LABELS, type CategoriaGasto } from "@/types";
|
||||||
|
|
||||||
|
interface ObraComparativo {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
estado: string;
|
||||||
|
porcentajeAvance: number;
|
||||||
|
presupuestado: number;
|
||||||
|
ejecutado: number;
|
||||||
|
gastado: number;
|
||||||
|
variacion: number;
|
||||||
|
variacionPorcentaje: number;
|
||||||
|
categorias: {
|
||||||
|
categoria: string;
|
||||||
|
presupuestado: number;
|
||||||
|
gastado: number;
|
||||||
|
variacion: number;
|
||||||
|
variacionPorcentaje: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Resumen {
|
||||||
|
totalObras: number;
|
||||||
|
totalPresupuestado: number;
|
||||||
|
totalEjecutado: number;
|
||||||
|
totalGastado: number;
|
||||||
|
variacionTotal: number;
|
||||||
|
variacionPorcentaje: number;
|
||||||
|
obrasConSobrecosto: number;
|
||||||
|
obrasBajoPresupuesto: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComparativoData {
|
||||||
|
obras: ObraComparativo[];
|
||||||
|
resumen: Resumen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComparativoDashboard() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<ComparativoData | null>(null);
|
||||||
|
const [selectedObra, setSelectedObra] = useState<string>("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/dashboard/comparativo");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Error al cargar datos");
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No se pudieron cargar los datos del dashboard",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
value.toLocaleString("es-MX", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "MXN",
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatPercentage = (value: number) =>
|
||||||
|
`${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||||
|
<AlertTriangle className="h-12 w-12 mb-4" />
|
||||||
|
<p>No hay datos disponibles</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { obras, resumen } = data;
|
||||||
|
const filteredObras =
|
||||||
|
selectedObra === "all" ? obras : obras.filter((o) => o.id === selectedObra);
|
||||||
|
|
||||||
|
// Calcular resumen filtrado
|
||||||
|
const filteredResumen =
|
||||||
|
selectedObra === "all"
|
||||||
|
? resumen
|
||||||
|
: {
|
||||||
|
totalObras: 1,
|
||||||
|
totalPresupuestado: filteredObras[0]?.presupuestado || 0,
|
||||||
|
totalEjecutado: filteredObras[0]?.ejecutado || 0,
|
||||||
|
totalGastado: filteredObras[0]?.gastado || 0,
|
||||||
|
variacionTotal: filteredObras[0]?.variacion || 0,
|
||||||
|
variacionPorcentaje: filteredObras[0]?.variacionPorcentaje || 0,
|
||||||
|
obrasConSobrecosto: filteredObras[0]?.variacion < 0 ? 1 : 0,
|
||||||
|
obrasBajoPresupuesto: filteredObras[0]?.variacion > 0 ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combinar categorías de todas las obras filtradas
|
||||||
|
const categoriasCombinadas: Record<string, { presupuestado: number; gastado: number }> = {};
|
||||||
|
for (const obra of filteredObras) {
|
||||||
|
for (const cat of obra.categorias) {
|
||||||
|
if (!categoriasCombinadas[cat.categoria]) {
|
||||||
|
categoriasCombinadas[cat.categoria] = { presupuestado: 0, gastado: 0 };
|
||||||
|
}
|
||||||
|
categoriasCombinadas[cat.categoria].presupuestado += cat.presupuestado;
|
||||||
|
categoriasCombinadas[cat.categoria].gastado += cat.gastado;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filtro de obra */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-6 w-6" />
|
||||||
|
Comparativo Presupuesto vs Ejecutado
|
||||||
|
</h2>
|
||||||
|
<Select value={selectedObra} onValueChange={setSelectedObra}>
|
||||||
|
<SelectTrigger className="w-[250px]">
|
||||||
|
<SelectValue placeholder="Todas las obras" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas las obras</SelectItem>
|
||||||
|
{obras.map((obra) => (
|
||||||
|
<SelectItem key={obra.id} value={obra.id}>
|
||||||
|
{obra.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resumen Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-blue-500" />
|
||||||
|
Total Presupuestado
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatCurrency(filteredResumen.totalPresupuestado)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{filteredResumen.totalObras} obra(s)
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-amber-500" />
|
||||||
|
Total Gastado
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatCurrency(filteredResumen.totalGastado)}
|
||||||
|
</p>
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
filteredResumen.totalPresupuestado > 0
|
||||||
|
? (filteredResumen.totalGastado / filteredResumen.totalPresupuestado) * 100
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
{filteredResumen.variacionTotal >= 0 ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
Variación
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
filteredResumen.variacionTotal >= 0 ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCurrency(filteredResumen.variacionTotal)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
filteredResumen.variacionPorcentaje >= 0 ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPercentage(filteredResumen.variacionPorcentaje)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-slate-500" />
|
||||||
|
Estado de Obras
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-sm">{filteredResumen.obrasBajoPresupuesto} bajo</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
|
<span className="text-sm">{filteredResumen.obrasConSobrecosto} sobre</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desglose por Categoría */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Desglose por Categoría</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Comparación de presupuesto vs gasto real por categoría
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Categoría</TableHead>
|
||||||
|
<TableHead className="text-right">Presupuestado</TableHead>
|
||||||
|
<TableHead className="text-right">Gastado</TableHead>
|
||||||
|
<TableHead className="text-right">Variación</TableHead>
|
||||||
|
<TableHead className="w-[150px]">Uso</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(categoriasCombinadas)
|
||||||
|
.sort((a, b) => b[1].presupuestado - a[1].presupuestado)
|
||||||
|
.map(([categoria, datos]) => {
|
||||||
|
const variacion = datos.presupuestado - datos.gastado;
|
||||||
|
const porcentajeUso =
|
||||||
|
datos.presupuestado > 0
|
||||||
|
? (datos.gastado / datos.presupuestado) * 100
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<TableRow key={categoria}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{CATEGORIA_GASTO_LABELS[categoria as CategoriaGasto] || categoria}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatCurrency(datos.presupuestado)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatCurrency(datos.gastado)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`text-right font-mono ${
|
||||||
|
variacion >= 0 ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCurrency(variacion)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={Math.min(100, porcentajeUso)}
|
||||||
|
className={`h-2 ${porcentajeUso > 100 ? "bg-red-100" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground w-12">
|
||||||
|
{porcentajeUso.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tabla de Obras */}
|
||||||
|
{selectedObra === "all" && obras.length > 1 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Comparativo por Obra</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Resumen de presupuesto vs gasto para cada obra
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Obra</TableHead>
|
||||||
|
<TableHead>Estado</TableHead>
|
||||||
|
<TableHead className="text-right">Presupuesto</TableHead>
|
||||||
|
<TableHead className="text-right">Gastado</TableHead>
|
||||||
|
<TableHead className="text-right">Variación</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Avance</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{obras.map((obra) => (
|
||||||
|
<TableRow key={obra.id}>
|
||||||
|
<TableCell className="font-medium">{obra.nombre}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{ESTADO_OBRA_LABELS[obra.estado as keyof typeof ESTADO_OBRA_LABELS] || obra.estado}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatCurrency(obra.presupuestado)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatCurrency(obra.gastado)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`text-right font-mono ${
|
||||||
|
obra.variacion >= 0 ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCurrency(obra.variacion)}
|
||||||
|
<span className="text-xs ml-1">
|
||||||
|
({obra.variacionPorcentaje >= 0 ? "+" : ""}
|
||||||
|
{obra.variacionPorcentaje.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={obra.porcentajeAvance} className="h-2" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{obra.porcentajeAvance.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/dashboard/index.ts
Normal file
1
src/components/dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ComparativoDashboard } from "./comparativo-dashboard";
|
||||||
644
src/components/estimaciones/estimacion-detail.tsx
Normal file
644
src/components/estimaciones/estimacion-detail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
639
src/components/estimaciones/estimacion-form.tsx
Normal file
639
src/components/estimaciones/estimacion-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
416
src/components/estimaciones/estimaciones-list.tsx
Normal file
416
src/components/estimaciones/estimaciones-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/estimaciones/index.ts
Normal file
3
src/components/estimaciones/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { EstimacionesList } from "./estimaciones-list";
|
||||||
|
export { EstimacionForm } from "./estimacion-form";
|
||||||
|
export { EstimacionDetail } from "./estimacion-detail";
|
||||||
@@ -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,13 +240,104 @@ 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
|
||||||
const tareaId = task.id.replace("tarea-", "");
|
if (!task.id.startsWith("tarea-")) {
|
||||||
onTaskUpdate(tareaId, task.start, task.end);
|
toast({
|
||||||
|
title: "No permitido",
|
||||||
|
description: "Solo puede modificar las fechas de las tareas",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
242
src/components/precios/actualizacion-masiva.tsx
Normal file
242
src/components/precios/actualizacion-masiva.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/precios/index.ts
Normal file
1
src/components/precios/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ActualizacionMasiva } from "./actualizacion-masiva";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
372
src/components/programacion/gantt-chart.tsx
Normal file
372
src/components/programacion/gantt-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/programacion/index.ts
Normal file
1
src/components/programacion/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { GanttChart } from "./gantt-chart";
|
||||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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
178
src/components/ui/form.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -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 }
|
||||||
|
|||||||
141
src/lib/validations/estimaciones.ts
Normal file
141
src/lib/validations/estimaciones.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user