From 56e39af3ffcb165fd1a17045edeb88fe1ad5ef38 Mon Sep 17 00:00:00 2001 From: Mexus Date: Thu, 5 Feb 2026 07:14:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Implement=20complete=20APU=20(An=C3=A1l?= =?UTF-8?q?isis=20de=20Precios=20Unitarios)=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High priority features: - APU CRUD with materials, labor, and equipment breakdown - Labor catalog with FSR (Factor de Salario Real) calculation - Equipment catalog with hourly cost calculation - Link APU to budget line items (partidas) - Explosion de insumos (consolidated materials list) Additional features: - Duplicate APU functionality - Excel export for explosion de insumos - Search and filters in APU list - Price validation alerts for outdated prices - PDF report export for APU New components: - APUForm, APUList, APUDetail - ManoObraForm, EquipoForm - ConfiguracionAPUForm - VincularAPUDialog - PartidasManager - ExplosionInsumos - APUPDF New UI components: - Alert component - Tooltip component Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 106 ++- package.json | 3 +- prisma/schema.prisma | 179 +++++ src/app/(dashboard)/apu/[id]/editar/page.tsx | 99 +++ src/app/(dashboard)/apu/[id]/page.tsx | 52 ++ .../(dashboard)/apu/configuracion/page.tsx | 46 ++ .../apu/equipos/[id]/editar/page.tsx | 44 ++ .../(dashboard)/apu/equipos/nuevo/page.tsx | 16 + src/app/(dashboard)/apu/equipos/page.tsx | 155 ++++ .../apu/mano-obra/[id]/editar/page.tsx | 44 ++ .../(dashboard)/apu/mano-obra/nuevo/page.tsx | 17 + src/app/(dashboard)/apu/mano-obra/page.tsx | 152 ++++ src/app/(dashboard)/apu/nuevo/page.tsx | 109 +++ src/app/(dashboard)/apu/page.tsx | 40 + .../obras/[id]/obra-detail-client.tsx | 87 ++- src/app/(dashboard)/obras/[id]/page.tsx | 16 +- src/app/api/apu/[id]/duplicate/route.ts | 104 +++ src/app/api/apu/[id]/route.ts | 318 ++++++++ src/app/api/apu/configuracion/route.ts | 72 ++ src/app/api/apu/equipos/[id]/route.ts | 171 ++++ src/app/api/apu/equipos/route.ts | 92 +++ src/app/api/apu/mano-obra/[id]/route.ts | 174 +++++ src/app/api/apu/mano-obra/route.ts | 94 +++ src/app/api/apu/route.ts | 219 ++++++ .../api/presupuestos/[id]/explosion/route.ts | 184 +++++ .../[id]/partidas/[partidaId]/route.ts | 228 ++++++ .../api/presupuestos/[id]/partidas/route.ts | 186 +++++ src/app/api/presupuestos/route.ts | 99 +++ src/components/apu/apu-detail.tsx | 690 ++++++++++++++++ src/components/apu/apu-form.tsx | 734 ++++++++++++++++++ src/components/apu/apu-list.tsx | 413 ++++++++++ src/components/apu/configuracion-apu-form.tsx | 202 +++++ src/components/apu/equipo-form.tsx | 377 +++++++++ src/components/apu/index.ts | 7 + src/components/apu/mano-obra-form.tsx | 317 ++++++++ src/components/apu/vincular-apu-dialog.tsx | 187 +++++ src/components/layout/sidebar.tsx | 6 + src/components/pdf/apu-pdf.tsx | 300 +++++++ src/components/pdf/index.ts | 1 + .../presupuesto/explosion-insumos.tsx | 457 +++++++++++ src/components/presupuesto/index.ts | 2 + .../presupuesto/partidas-manager.tsx | 586 ++++++++++++++ src/components/ui/alert.tsx | 59 ++ src/components/ui/tooltip.tsx | 30 + src/lib/validations.ts | 10 + src/lib/validations/apu.ts | 259 ++++++ src/types/index.ts | 54 ++ 47 files changed, 7779 insertions(+), 18 deletions(-) create mode 100644 src/app/(dashboard)/apu/[id]/editar/page.tsx create mode 100644 src/app/(dashboard)/apu/[id]/page.tsx create mode 100644 src/app/(dashboard)/apu/configuracion/page.tsx create mode 100644 src/app/(dashboard)/apu/equipos/[id]/editar/page.tsx create mode 100644 src/app/(dashboard)/apu/equipos/nuevo/page.tsx create mode 100644 src/app/(dashboard)/apu/equipos/page.tsx create mode 100644 src/app/(dashboard)/apu/mano-obra/[id]/editar/page.tsx create mode 100644 src/app/(dashboard)/apu/mano-obra/nuevo/page.tsx create mode 100644 src/app/(dashboard)/apu/mano-obra/page.tsx create mode 100644 src/app/(dashboard)/apu/nuevo/page.tsx create mode 100644 src/app/(dashboard)/apu/page.tsx create mode 100644 src/app/api/apu/[id]/duplicate/route.ts create mode 100644 src/app/api/apu/[id]/route.ts create mode 100644 src/app/api/apu/configuracion/route.ts create mode 100644 src/app/api/apu/equipos/[id]/route.ts create mode 100644 src/app/api/apu/equipos/route.ts create mode 100644 src/app/api/apu/mano-obra/[id]/route.ts create mode 100644 src/app/api/apu/mano-obra/route.ts create mode 100644 src/app/api/apu/route.ts create mode 100644 src/app/api/presupuestos/[id]/explosion/route.ts create mode 100644 src/app/api/presupuestos/[id]/partidas/[partidaId]/route.ts create mode 100644 src/app/api/presupuestos/[id]/partidas/route.ts create mode 100644 src/app/api/presupuestos/route.ts create mode 100644 src/components/apu/apu-detail.tsx create mode 100644 src/components/apu/apu-form.tsx create mode 100644 src/components/apu/apu-list.tsx create mode 100644 src/components/apu/configuracion-apu-form.tsx create mode 100644 src/components/apu/equipo-form.tsx create mode 100644 src/components/apu/index.ts create mode 100644 src/components/apu/mano-obra-form.tsx create mode 100644 src/components/apu/vincular-apu-dialog.tsx create mode 100644 src/components/pdf/apu-pdf.tsx create mode 100644 src/components/presupuesto/explosion-insumos.tsx create mode 100644 src/components/presupuesto/index.ts create mode 100644 src/components/presupuesto/partidas-manager.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/validations/apu.ts diff --git a/package-lock.json b/package-lock.json index 4dda5f8..fb616e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", - "@radix-ui/react-tooltip": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.8", "@react-pdf/renderer": "^4.3.2", "autoprefixer": "^10.4.23", "bcryptjs": "^2.4.3", @@ -42,6 +42,7 @@ "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", "web-push": "^3.6.7", + "xlsx": "^0.18.5", "zod": "^3.23.8" }, "devDependencies": { @@ -3236,6 +3237,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3852,6 +3862,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3941,6 +3964,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3986,6 +4018,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5273,6 +5317,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -8051,6 +8104,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -9072,6 +9137,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9190,6 +9273,27 @@ "dev": true, "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a7d7d71..59f202b 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", - "@radix-ui/react-tooltip": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.8", "@react-pdf/renderer": "^4.3.2", "autoprefixer": "^10.4.23", "bcryptjs": "^2.4.3", @@ -56,6 +56,7 @@ "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", "web-push": "^3.6.7", + "xlsx": "^0.18.5", "zod": "^3.23.8" }, "devDependencies": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 162e1a4..2807b9c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,6 +82,38 @@ enum UnidadMedida { PIEZA ROLLO CAJA + HORA + JORNADA + VIAJE + LOTE + GLOBAL +} + +enum CategoriaManoObra { + PEON + AYUDANTE + OFICIAL_ALBANIL + OFICIAL_FIERRERO + OFICIAL_CARPINTERO + OFICIAL_PLOMERO + OFICIAL_ELECTRICISTA + CABO + MAESTRO_OBRA + OPERADOR_EQUIPO +} + +enum TipoEquipo { + MAQUINARIA_PESADA + MAQUINARIA_LIGERA + HERRAMIENTA_ELECTRICA + TRANSPORTE +} + +enum TipoInsumoAPU { + MATERIAL + MANO_OBRA + EQUIPO + HERRAMIENTA_MENOR } // ============== MODELS ============== @@ -137,6 +169,11 @@ model Empresa { empleados Empleado[] subcontratistas Subcontratista[] clientes Cliente[] + // APU Relations + categoriasTrabajo CategoriaTrabajoAPU[] + equiposMaquinaria EquipoMaquinaria[] + apus AnalisisPrecioUnitario[] + configuracionAPU ConfiguracionAPU? } model Cliente { @@ -267,6 +304,8 @@ model TareaObra { @@index([faseId]) @@index([estado]) + @@index([asignadoId]) + @@index([faseId, estado]) } model RegistroAvance { @@ -281,6 +320,8 @@ model RegistroAvance { createdAt DateTime @default(now()) @@index([obraId]) + @@index([registradoPorId]) + @@index([obraId, createdAt]) } model Presupuesto { @@ -312,6 +353,8 @@ model PartidaPresupuesto { categoria CategoriaGasto presupuestoId String presupuesto Presupuesto @relation(fields: [presupuestoId], references: [id], onDelete: Cascade) + apuId String? + apu AnalisisPrecioUnitario? @relation(fields: [apuId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -319,6 +362,7 @@ model PartidaPresupuesto { gastos Gasto[] @@index([presupuestoId]) + @@index([apuId]) } model Gasto { @@ -395,10 +439,12 @@ model Material { // Relations movimientos MovimientoInventario[] itemsOrden ItemOrdenCompra[] + insumosAPU InsumoAPU[] @@unique([codigo, empresaId]) @@index([empresaId]) @@index([nombre]) + @@index([activo, empresaId]) } model MovimientoInventario { @@ -470,6 +516,7 @@ model JornadaTrabajo { @@index([empleadoId]) @@index([fecha]) + @@index([empleadoId, fecha]) } model Subcontratista { @@ -773,6 +820,8 @@ model FotoAvance { @@index([obraId]) @@index([faseId]) @@index([fechaCaptura]) + @@index([subidoPorId]) + @@index([obraId, fechaCaptura]) } // ============== NOTIFICACIONES PUSH ============== @@ -894,4 +943,134 @@ model ActividadLog { @@index([empresaId]) @@index([tipo]) @@index([createdAt]) + @@index([obraId, createdAt]) + @@index([empresaId, createdAt]) +} + +// ============== ANÁLISIS DE PRECIOS UNITARIOS (APU) ============== + +model CategoriaTrabajoAPU { + id String @id @default(cuid()) + codigo String + nombre String + categoria CategoriaManoObra + salarioDiario Float + factorIMSS Float @default(0.2675) + factorINFONAVIT Float @default(0.05) + factorRetiro Float @default(0.02) + factorVacaciones Float @default(0.0411) + factorPrimaVac Float @default(0.0103) + factorAguinaldo Float @default(0.0411) + factorSalarioReal Float // Calculado: 1 + suma de factores + salarioReal Float // salarioDiario * FSR + activo Boolean @default(true) + empresaId String + empresa Empresa @relation(fields: [empresaId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + insumosAPU InsumoAPU[] + + @@unique([codigo, empresaId]) + @@index([empresaId]) + @@index([categoria]) +} + +model EquipoMaquinaria { + id String @id @default(cuid()) + codigo String + nombre String + tipo TipoEquipo + valorAdquisicion Float + vidaUtilHoras Float + valorRescate Float @default(0) + consumoCombustible Float? // Litros por hora + precioCombustible Float? // Precio por litro + factorMantenimiento Float @default(0.60) + costoOperador Float? // Costo por hora del operador + costoHorario Float // Calculado + activo Boolean @default(true) + empresaId String + empresa Empresa @relation(fields: [empresaId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + insumosAPU InsumoAPU[] + + @@unique([codigo, empresaId]) + @@index([empresaId]) + @@index([tipo]) +} + +model AnalisisPrecioUnitario { + id String @id @default(cuid()) + codigo String + descripcion String + unidad UnidadMedida + rendimientoDiario Float? // Cantidad de unidades por jornada + costoMateriales Float @default(0) + costoManoObra Float @default(0) + costoEquipo Float @default(0) + costoHerramienta Float @default(0) + costoDirecto Float @default(0) + porcentajeIndirectos Float @default(0) + costoIndirectos Float @default(0) + porcentajeUtilidad Float @default(0) + costoUtilidad Float @default(0) + precioUnitario Float @default(0) + activo Boolean @default(true) + empresaId String + empresa Empresa @relation(fields: [empresaId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + insumos InsumoAPU[] + partidas PartidaPresupuesto[] + + @@unique([codigo, empresaId]) + @@index([empresaId]) + @@index([unidad]) +} + +model InsumoAPU { + id String @id @default(cuid()) + tipo TipoInsumoAPU + descripcion String + unidad UnidadMedida + cantidad Float + desperdicio Float @default(0) // Porcentaje + cantidadConDesperdicio Float + rendimiento Float? // Para mano de obra: unidades por jornada + precioUnitario Float + importe Float + apuId String + apu AnalisisPrecioUnitario @relation(fields: [apuId], references: [id], onDelete: Cascade) + materialId String? + material Material? @relation(fields: [materialId], references: [id]) + categoriaManoObraId String? + categoriaManoObra CategoriaTrabajoAPU? @relation(fields: [categoriaManoObraId], references: [id]) + equipoId String? + equipo EquipoMaquinaria? @relation(fields: [equipoId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([apuId]) + @@index([tipo]) + @@index([materialId]) + @@index([categoriaManoObraId]) + @@index([equipoId]) +} + +model ConfiguracionAPU { + id String @id @default(cuid()) + porcentajeHerramientaMenor Float @default(3) + porcentajeIndirectos Float @default(8) + porcentajeUtilidad Float @default(10) + empresaId String @unique + empresa Empresa @relation(fields: [empresaId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } diff --git a/src/app/(dashboard)/apu/[id]/editar/page.tsx b/src/app/(dashboard)/apu/[id]/editar/page.tsx new file mode 100644 index 0000000..b78154a --- /dev/null +++ b/src/app/(dashboard)/apu/[id]/editar/page.tsx @@ -0,0 +1,99 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import { APUForm } from "@/components/apu"; + +async function getData(empresaId: string, apuId: string) { + const [apu, materiales, categoriasManoObra, equipos, config] = + await Promise.all([ + prisma.analisisPrecioUnitario.findFirst({ + where: { id: apuId, empresaId }, + include: { + insumos: true, + }, + }), + prisma.material.findMany({ + where: { empresaId, activo: true }, + select: { + id: true, + codigo: true, + nombre: true, + unidad: true, + precioUnitario: true, + }, + orderBy: { nombre: "asc" }, + }), + prisma.categoriaTrabajoAPU.findMany({ + where: { empresaId, activo: true }, + select: { + id: true, + codigo: true, + nombre: true, + categoria: true, + salarioReal: true, + }, + orderBy: { nombre: "asc" }, + }), + prisma.equipoMaquinaria.findMany({ + where: { empresaId, activo: true }, + select: { + id: true, + codigo: true, + nombre: true, + tipo: true, + costoHorario: true, + }, + orderBy: { nombre: "asc" }, + }), + prisma.configuracionAPU.findUnique({ + where: { empresaId }, + }), + ]); + + const configuracion = config || { + porcentajeHerramientaMenor: 3, + porcentajeIndirectos: 8, + porcentajeUtilidad: 10, + }; + + return { apu, materiales, categoriasManoObra, equipos, configuracion }; +} + +export default async function EditarAPUPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await auth(); + const { id } = await params; + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const { apu, materiales, categoriasManoObra, equipos, configuracion } = + await getData(session.user.empresaId, id); + + if (!apu) { + notFound(); + } + + return ( +
+
+

Editar APU

+

+ Modificando {apu.codigo} - {apu.descripcion} +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/apu/[id]/page.tsx b/src/app/(dashboard)/apu/[id]/page.tsx new file mode 100644 index 0000000..5c2052a --- /dev/null +++ b/src/app/(dashboard)/apu/[id]/page.tsx @@ -0,0 +1,52 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import { APUDetail } from "@/components/apu"; + +async function getAPU(empresaId: string, id: string) { + const apu = await prisma.analisisPrecioUnitario.findFirst({ + where: { id, empresaId }, + include: { + insumos: { + include: { + material: { select: { codigo: true, nombre: true } }, + categoriaManoObra: { select: { codigo: true, nombre: true } }, + equipo: { select: { codigo: true, nombre: true } }, + }, + orderBy: { tipo: "asc" }, + }, + partidas: { + include: { + presupuesto: { + include: { + obra: { select: { id: true, nombre: true } }, + }, + }, + }, + }, + }, + }); + + return apu; +} + +export default async function APUDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await auth(); + const { id } = await params; + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const apu = await getAPU(session.user.empresaId, id); + + if (!apu) { + notFound(); + } + + return ; +} diff --git a/src/app/(dashboard)/apu/configuracion/page.tsx b/src/app/(dashboard)/apu/configuracion/page.tsx new file mode 100644 index 0000000..e509df0 --- /dev/null +++ b/src/app/(dashboard)/apu/configuracion/page.tsx @@ -0,0 +1,46 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { ConfiguracionAPUForm } from "@/components/apu"; + +async function getConfiguracion(empresaId: string) { + let config = await prisma.configuracionAPU.findUnique({ + where: { empresaId }, + }); + + if (!config) { + config = await prisma.configuracionAPU.create({ + data: { + empresaId, + porcentajeHerramientaMenor: 3, + porcentajeIndirectos: 8, + porcentajeUtilidad: 10, + }, + }); + } + + return config; +} + +export default async function ConfiguracionAPUPage() { + const session = await auth(); + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const configuracion = await getConfiguracion(session.user.empresaId); + + return ( +
+
+

Configuracion de APU

+

+ Define los porcentajes por defecto para nuevos analisis de precios + unitarios +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/apu/equipos/[id]/editar/page.tsx b/src/app/(dashboard)/apu/equipos/[id]/editar/page.tsx new file mode 100644 index 0000000..c813719 --- /dev/null +++ b/src/app/(dashboard)/apu/equipos/[id]/editar/page.tsx @@ -0,0 +1,44 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import { EquipoForm } from "@/components/apu"; + +async function getEquipo(empresaId: string, id: string) { + const equipo = await prisma.equipoMaquinaria.findFirst({ + where: { id, empresaId }, + }); + + return equipo; +} + +export default async function EditarEquipoPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await auth(); + const { id } = await params; + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const equipo = await getEquipo(session.user.empresaId, id); + + if (!equipo) { + notFound(); + } + + return ( +
+
+

Editar Equipo

+

+ Modificando {equipo.codigo} - {equipo.nombre} +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/apu/equipos/nuevo/page.tsx b/src/app/(dashboard)/apu/equipos/nuevo/page.tsx new file mode 100644 index 0000000..d3a4260 --- /dev/null +++ b/src/app/(dashboard)/apu/equipos/nuevo/page.tsx @@ -0,0 +1,16 @@ +import { EquipoForm } from "@/components/apu"; + +export default function NuevoEquipoPage() { + return ( +
+
+

Nuevo Equipo o Maquinaria

+

+ Registra un nuevo equipo con calculo automatico de costo horario +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/apu/equipos/page.tsx b/src/app/(dashboard)/apu/equipos/page.tsx new file mode 100644 index 0000000..1b66ada --- /dev/null +++ b/src/app/(dashboard)/apu/equipos/page.tsx @@ -0,0 +1,155 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import Link from "next/link"; +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, +} from "@/components/ui/table"; +import { Plus, Pencil, Wrench } from "lucide-react"; +import { TIPO_EQUIPO_LABELS, TIPO_EQUIPO_COLORS } from "@/types"; + +async function getEquipos(empresaId: string) { + const equipos = await prisma.equipoMaquinaria.findMany({ + where: { empresaId }, + include: { + _count: { + select: { insumosAPU: true }, + }, + }, + orderBy: { nombre: "asc" }, + }); + + return equipos; +} + +export default async function EquiposPage() { + const session = await auth(); + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const equipos = await getEquipos(session.user.empresaId); + + return ( +
+
+
+

Catalogo de Equipos y Maquinaria

+

+ Gestiona los equipos con calculo automatico de costo horario +

+
+ +
+ + + + + + Equipos y Maquinaria + + + Lista de equipos con su costo horario calculado + + + + {equipos.length === 0 ? ( +
+ +

No hay equipos registrados

+ +
+ ) : ( + + + + Codigo + Nombre + Tipo + Valor Adquisicion + Vida Util (hrs) + Costo Horario + En uso + Estado + + + + + {equipos.map((equipo) => ( + + {equipo.codigo} + {equipo.nombre} + + + {TIPO_EQUIPO_LABELS[equipo.tipo]} + + + + ${equipo.valorAdquisicion.toLocaleString("es-MX")} + + + {equipo.vidaUtilHoras.toLocaleString("es-MX")} + + + ${equipo.costoHorario.toFixed(2)}/hr + + + {equipo._count.insumosAPU > 0 ? ( + + {equipo._count.insumosAPU} APU + {equipo._count.insumosAPU !== 1 ? "s" : ""} + + ) : ( + - + )} + + + + {equipo.activo ? "Activo" : "Inactivo"} + + + + + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/apu/mano-obra/[id]/editar/page.tsx b/src/app/(dashboard)/apu/mano-obra/[id]/editar/page.tsx new file mode 100644 index 0000000..cd1badb --- /dev/null +++ b/src/app/(dashboard)/apu/mano-obra/[id]/editar/page.tsx @@ -0,0 +1,44 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { notFound } from "next/navigation"; +import { ManoObraForm } from "@/components/apu"; + +async function getCategoria(empresaId: string, id: string) { + const categoria = await prisma.categoriaTrabajoAPU.findFirst({ + where: { id, empresaId }, + }); + + return categoria; +} + +export default async function EditarManoObraPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await auth(); + const { id } = await params; + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const categoria = await getCategoria(session.user.empresaId, id); + + if (!categoria) { + notFound(); + } + + return ( +
+
+

Editar Categoria de Mano de Obra

+

+ Modificando {categoria.codigo} - {categoria.nombre} +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/apu/mano-obra/nuevo/page.tsx b/src/app/(dashboard)/apu/mano-obra/nuevo/page.tsx new file mode 100644 index 0000000..f0815e8 --- /dev/null +++ b/src/app/(dashboard)/apu/mano-obra/nuevo/page.tsx @@ -0,0 +1,17 @@ +import { ManoObraForm } from "@/components/apu"; + +export default function NuevaManoObraPage() { + return ( +
+
+

Nueva Categoria de Mano de Obra

+

+ Registra una nueva categoria de trabajador con su salario y factores + de FSR +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/apu/mano-obra/page.tsx b/src/app/(dashboard)/apu/mano-obra/page.tsx new file mode 100644 index 0000000..615976b --- /dev/null +++ b/src/app/(dashboard)/apu/mano-obra/page.tsx @@ -0,0 +1,152 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import Link from "next/link"; +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, +} from "@/components/ui/table"; +import { Plus, Pencil, Users } from "lucide-react"; +import { CATEGORIA_MANO_OBRA_LABELS } from "@/types"; + +async function getCategorias(empresaId: string) { + const categorias = await prisma.categoriaTrabajoAPU.findMany({ + where: { empresaId }, + include: { + _count: { + select: { insumosAPU: true }, + }, + }, + orderBy: { categoria: "asc" }, + }); + + return categorias; +} + +export default async function ManoObraPage() { + const session = await auth(); + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const categorias = await getCategorias(session.user.empresaId); + + return ( +
+
+
+

Catalogo de Mano de Obra

+

+ Gestiona las categorias de trabajadores con Factor de Salario Real +

+
+ +
+ + + + + + Categorias de Trabajo + + + Lista de categorias con salario diario, FSR y salario real calculado + + + + {categorias.length === 0 ? ( +
+ +

No hay categorias de mano de obra registradas

+ +
+ ) : ( + + + + Codigo + Nombre + Categoria + Salario Diario + FSR + Salario Real + En uso + Estado + + + + + {categorias.map((cat) => ( + + {cat.codigo} + {cat.nombre} + + {CATEGORIA_MANO_OBRA_LABELS[cat.categoria]} + + + ${cat.salarioDiario.toFixed(2)} + + + {cat.factorSalarioReal.toFixed(4)} + + + ${cat.salarioReal.toFixed(2)} + + + {cat._count.insumosAPU > 0 ? ( + + {cat._count.insumosAPU} APU{cat._count.insumosAPU !== 1 ? "s" : ""} + + ) : ( + - + )} + + + + {cat.activo ? "Activo" : "Inactivo"} + + + + + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/apu/nuevo/page.tsx b/src/app/(dashboard)/apu/nuevo/page.tsx new file mode 100644 index 0000000..ef0a3f8 --- /dev/null +++ b/src/app/(dashboard)/apu/nuevo/page.tsx @@ -0,0 +1,109 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { APUForm } from "@/components/apu"; + +async function getData(empresaId: string, duplicarId?: string) { + const [materiales, categoriasManoObra, equipos, config, apuDuplicar] = + await Promise.all([ + prisma.material.findMany({ + where: { empresaId, activo: true }, + select: { + id: true, + codigo: true, + nombre: true, + unidad: true, + precioUnitario: true, + }, + orderBy: { nombre: "asc" }, + }), + prisma.categoriaTrabajoAPU.findMany({ + where: { empresaId, activo: true }, + select: { + id: true, + codigo: true, + nombre: true, + categoria: true, + salarioReal: true, + }, + orderBy: { nombre: "asc" }, + }), + prisma.equipoMaquinaria.findMany({ + where: { empresaId, activo: true }, + select: { + id: true, + codigo: true, + nombre: true, + tipo: true, + costoHorario: true, + }, + orderBy: { nombre: "asc" }, + }), + prisma.configuracionAPU.findUnique({ + where: { empresaId }, + }), + duplicarId + ? prisma.analisisPrecioUnitario.findFirst({ + where: { id: duplicarId, empresaId }, + include: { + insumos: true, + }, + }) + : null, + ]); + + const configuracion = config || { + porcentajeHerramientaMenor: 3, + porcentajeIndirectos: 8, + porcentajeUtilidad: 10, + }; + + return { materiales, categoriasManoObra, equipos, configuracion, apuDuplicar }; +} + +export default async function NuevoAPUPage({ + searchParams, +}: { + searchParams: Promise<{ duplicar?: string }>; +}) { + const session = await auth(); + const params = await searchParams; + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const { materiales, categoriasManoObra, equipos, configuracion, apuDuplicar } = + await getData(session.user.empresaId, params.duplicar); + + // Transform duplicated APU for form (without id to create new) + const apuData = apuDuplicar + ? { + ...apuDuplicar, + id: "", // Clear id to create new + codigo: `${apuDuplicar.codigo}-COPIA`, + } + : undefined; + + return ( +
+
+

+ {apuDuplicar ? "Duplicar APU" : "Nuevo Analisis de Precio Unitario"} +

+

+ {apuDuplicar + ? `Creando copia de ${apuDuplicar.codigo}` + : "Crea un nuevo APU con desglose de materiales, mano de obra y equipo"} +

+
+ + [0]["apu"]} + materiales={materiales} + categoriasManoObra={categoriasManoObra} + equipos={equipos} + configuracion={configuracion} + /> +
+ ); +} diff --git a/src/app/(dashboard)/apu/page.tsx b/src/app/(dashboard)/apu/page.tsx new file mode 100644 index 0000000..e7b460f --- /dev/null +++ b/src/app/(dashboard)/apu/page.tsx @@ -0,0 +1,40 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { APUList } from "@/components/apu"; + +async function getAPUs(empresaId: string) { + const apus = await prisma.analisisPrecioUnitario.findMany({ + where: { empresaId }, + include: { + _count: { + select: { partidas: true }, + }, + }, + orderBy: { codigo: "asc" }, + }); + + return apus; +} + +export default async function APUPage() { + const session = await auth(); + + if (!session?.user?.empresaId) { + return
Error: No se encontro la empresa
; + } + + const apus = await getAPUs(session.user.empresaId); + + return ( +
+
+

Analisis de Precios Unitarios

+

+ Gestiona los APUs para tus presupuestos de obra +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx index 0e2e387..7f37ef5 100644 --- a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx +++ b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx @@ -1,5 +1,7 @@ "use client"; +import { Suspense, lazy } from "react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -23,6 +25,7 @@ import { Clock, CheckCircle2, AlertCircle, + Loader2, } from "lucide-react"; import { formatCurrency, @@ -43,17 +46,46 @@ import { type CategoriaGasto, type CondicionClima, } from "@/types"; -import { GaleriaFotos } from "@/components/fotos/galeria-fotos"; -import { BitacoraObra } from "@/components/bitacora/bitacora-obra"; -import { ControlAsistencia } from "@/components/asistencia/control-asistencia"; -import { OrdenesCompra } from "@/components/ordenes/ordenes-compra"; -import { DiagramaGantt } from "@/components/gantt/diagrama-gantt"; import { ExportPDFMenu, ReporteObraPDF, GastosPDF, BitacoraPDF, } from "@/components/pdf"; +import { PartidasManager, ExplosionInsumos } from "@/components/presupuesto"; + +// Componente de carga +const LoadingSpinner = () => ( +
+ +
+); + +// Importaciones dinámicas para componentes pesados +const GaleriaFotos = dynamic( + () => import("@/components/fotos/galeria-fotos").then((mod) => mod.GaleriaFotos), + { loading: () => , ssr: false } +); + +const BitacoraObra = dynamic( + () => import("@/components/bitacora/bitacora-obra").then((mod) => mod.BitacoraObra), + { loading: () => , ssr: false } +); + +const ControlAsistencia = dynamic( + () => import("@/components/asistencia/control-asistencia").then((mod) => mod.ControlAsistencia), + { loading: () => , ssr: false } +); + +const OrdenesCompra = dynamic( + () => import("@/components/ordenes/ordenes-compra").then((mod) => mod.OrdenesCompra), + { loading: () => , ssr: false } +); + +const DiagramaGantt = dynamic( + () => import("@/components/gantt/diagrama-gantt").then((mod) => mod.DiagramaGantt), + { loading: () => , ssr: false } +); interface ObraDetailProps { obra: { @@ -98,7 +130,17 @@ interface ObraDetailProps { id: string; codigo: string; descripcion: string; + unidad: import("@prisma/client").UnidadMedida; + cantidad: number; + precioUnitario: number; total: number; + categoria: CategoriaGasto; + apu: { + id: string; + codigo: string; + descripcion: string; + precioUnitario: number; + } | null; }[]; }[]; gastos: { @@ -526,7 +568,7 @@ export function ObraDetailClient({ obra }: ObraDetailProps) { ) : ( -
+
{obra.presupuestos.map((presupuesto) => ( @@ -539,18 +581,33 @@ export function ObraDetailClient({ obra }: ObraDetailProps) { {presupuesto.partidas.length} partidas
-
-

- {formatCurrency(presupuesto.total)} -

- - {presupuesto.aprobado ? "Aprobado" : "Pendiente"} - +
+ +
+

+ {formatCurrency(presupuesto.total)} +

+ + {presupuesto.aprobado ? "Aprobado" : "Pendiente"} + +
+ + + ))}
diff --git a/src/app/(dashboard)/obras/[id]/page.tsx b/src/app/(dashboard)/obras/[id]/page.tsx index 8b1c45a..d37c248 100644 --- a/src/app/(dashboard)/obras/[id]/page.tsx +++ b/src/app/(dashboard)/obras/[id]/page.tsx @@ -23,7 +23,21 @@ async function getObra(id: string, empresaId: string) { orderBy: { orden: "asc" }, }, presupuestos: { - include: { partidas: true }, + include: { + partidas: { + include: { + apu: { + select: { + id: true, + codigo: true, + descripcion: true, + precioUnitario: true, + }, + }, + }, + orderBy: { codigo: "asc" }, + }, + }, orderBy: { createdAt: "desc" }, }, gastos: { diff --git a/src/app/api/apu/[id]/duplicate/route.ts b/src/app/api/apu/[id]/duplicate/route.ts new file mode 100644 index 0000000..d545399 --- /dev/null +++ b/src/app/api/apu/[id]/duplicate/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function POST( + 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; + + // Get the original APU with all insumos + const original = await prisma.analisisPrecioUnitario.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + include: { + insumos: true, + }, + }); + + if (!original) { + return NextResponse.json({ error: "APU no encontrado" }, { status: 404 }); + } + + // Generate a unique code for the copy + let newCodigo = `${original.codigo}-COPIA`; + let counter = 1; + + while (true) { + const existing = await prisma.analisisPrecioUnitario.findFirst({ + where: { + codigo: newCodigo, + empresaId: session.user.empresaId, + }, + }); + + if (!existing) break; + + counter++; + newCodigo = `${original.codigo}-COPIA${counter}`; + } + + // Create the duplicate APU with all its insumos + const duplicate = await prisma.analisisPrecioUnitario.create({ + data: { + codigo: newCodigo, + descripcion: `${original.descripcion} (Copia)`, + unidad: original.unidad, + rendimientoDiario: original.rendimientoDiario, + costoMateriales: original.costoMateriales, + costoManoObra: original.costoManoObra, + costoEquipo: original.costoEquipo, + costoHerramienta: original.costoHerramienta, + costoDirecto: original.costoDirecto, + porcentajeIndirectos: original.porcentajeIndirectos, + costoIndirectos: original.costoIndirectos, + porcentajeUtilidad: original.porcentajeUtilidad, + costoUtilidad: original.costoUtilidad, + precioUnitario: original.precioUnitario, + empresaId: session.user.empresaId, + insumos: { + create: original.insumos.map((insumo) => ({ + tipo: insumo.tipo, + descripcion: insumo.descripcion, + unidad: insumo.unidad, + cantidad: insumo.cantidad, + desperdicio: insumo.desperdicio, + cantidadConDesperdicio: insumo.cantidadConDesperdicio, + rendimiento: insumo.rendimiento, + precioUnitario: insumo.precioUnitario, + importe: insumo.importe, + materialId: insumo.materialId, + categoriaManoObraId: insumo.categoriaManoObraId, + equipoId: insumo.equipoId, + })), + }, + }, + include: { + insumos: { + include: { + material: true, + categoriaManoObra: true, + equipo: true, + }, + }, + }, + }); + + return NextResponse.json(duplicate); + } catch (error) { + console.error("Error duplicating APU:", error); + return NextResponse.json( + { error: "Error al duplicar el APU" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/apu/[id]/route.ts b/src/app/api/apu/[id]/route.ts new file mode 100644 index 0000000..aeb4922 --- /dev/null +++ b/src/app/api/apu/[id]/route.ts @@ -0,0 +1,318 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + apuUpdateSchema, + calcularImporteInsumo, + calcularTotalesAPU, +} from "@/lib/validations/apu"; + +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 apu = await prisma.analisisPrecioUnitario.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + include: { + insumos: { + include: { + material: true, + categoriaManoObra: true, + equipo: true, + }, + orderBy: { tipo: "asc" }, + }, + partidas: { + include: { + presupuesto: { + include: { + obra: { + select: { id: true, nombre: true }, + }, + }, + }, + }, + }, + }, + }); + + if (!apu) { + return NextResponse.json({ error: "APU no encontrado" }, { status: 404 }); + } + + return NextResponse.json(apu); + } catch (error) { + console.error("Error fetching APU:", error); + return NextResponse.json( + { error: "Error al obtener el APU" }, + { status: 500 } + ); + } +} + +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(); + const validatedData = apuUpdateSchema.parse({ ...body, id }); + + // Verify ownership + const existing = await prisma.analisisPrecioUnitario.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + }); + + if (!existing) { + return NextResponse.json({ error: "APU no encontrado" }, { status: 404 }); + } + + // Check for duplicate codigo + if (validatedData.codigo && validatedData.codigo !== existing.codigo) { + const duplicate = await prisma.analisisPrecioUnitario.findFirst({ + where: { + codigo: validatedData.codigo, + empresaId: session.user.empresaId, + NOT: { id }, + }, + }); + + if (duplicate) { + return NextResponse.json( + { error: "Ya existe un APU con ese codigo" }, + { status: 400 } + ); + } + } + + // Get configuration + let config = await prisma.configuracionAPU.findUnique({ + where: { empresaId: session.user.empresaId }, + }); + + if (!config) { + config = await prisma.configuracionAPU.create({ + data: { + empresaId: session.user.empresaId, + porcentajeHerramientaMenor: 3, + porcentajeIndirectos: 8, + porcentajeUtilidad: 10, + }, + }); + } + + const porcentajeIndirectos = validatedData.porcentajeIndirectos ?? existing.porcentajeIndirectos; + const porcentajeUtilidad = validatedData.porcentajeUtilidad ?? existing.porcentajeUtilidad; + + // If insumos are provided, recalculate everything + if (validatedData.insumos && validatedData.insumos.length > 0) { + // Process insumos and calculate importes + const insumosConCalculos = await Promise.all( + validatedData.insumos.map(async (insumo) => { + let precioUnitario = insumo.precioUnitario; + + if (insumo.materialId) { + const material = await prisma.material.findUnique({ + where: { id: insumo.materialId }, + }); + if (material) { + precioUnitario = material.precioUnitario; + } + } else if (insumo.categoriaManoObraId) { + const categoria = await prisma.categoriaTrabajoAPU.findUnique({ + where: { id: insumo.categoriaManoObraId }, + }); + if (categoria) { + precioUnitario = categoria.salarioReal; + } + } else if (insumo.equipoId) { + const equipo = await prisma.equipoMaquinaria.findUnique({ + where: { id: insumo.equipoId }, + }); + if (equipo) { + precioUnitario = equipo.costoHorario; + } + } + + const { cantidadConDesperdicio, importe } = calcularImporteInsumo({ + tipo: insumo.tipo, + cantidad: insumo.cantidad, + desperdicio: insumo.desperdicio ?? 0, + rendimiento: insumo.rendimiento, + precioUnitario, + }); + + return { + ...insumo, + precioUnitario, + cantidadConDesperdicio, + importe, + }; + }) + ); + + // Calculate totals + const totales = calcularTotalesAPU(insumosConCalculos, { + porcentajeHerramientaMenor: config.porcentajeHerramientaMenor, + porcentajeIndirectos, + porcentajeUtilidad, + }); + + // Update APU with new insumos (delete old ones first) + const apu = await prisma.$transaction(async (tx) => { + // Delete existing insumos + await tx.insumoAPU.deleteMany({ + where: { apuId: id }, + }); + + // Update APU + return tx.analisisPrecioUnitario.update({ + where: { id }, + data: { + codigo: validatedData.codigo ?? existing.codigo, + descripcion: validatedData.descripcion ?? existing.descripcion, + unidad: validatedData.unidad ?? existing.unidad, + rendimientoDiario: validatedData.rendimientoDiario, + costoMateriales: totales.costoMateriales, + costoManoObra: totales.costoManoObra, + costoEquipo: totales.costoEquipo, + costoHerramienta: totales.costoHerramienta, + costoDirecto: totales.costoDirecto, + porcentajeIndirectos, + costoIndirectos: totales.costoIndirectos, + porcentajeUtilidad, + costoUtilidad: totales.costoUtilidad, + precioUnitario: totales.precioUnitario, + insumos: { + create: insumosConCalculos.map((insumo) => ({ + tipo: insumo.tipo, + descripcion: insumo.descripcion, + unidad: insumo.unidad, + cantidad: insumo.cantidad, + desperdicio: insumo.desperdicio ?? 0, + cantidadConDesperdicio: insumo.cantidadConDesperdicio, + rendimiento: insumo.rendimiento, + precioUnitario: insumo.precioUnitario, + importe: insumo.importe, + materialId: insumo.materialId, + categoriaManoObraId: insumo.categoriaManoObraId, + equipoId: insumo.equipoId, + })), + }, + }, + include: { + insumos: { + include: { + material: true, + categoriaManoObra: true, + equipo: true, + }, + }, + }, + }); + }); + + return NextResponse.json(apu); + } + + // Simple update without insumos changes + const apu = await prisma.analisisPrecioUnitario.update({ + where: { id }, + data: { + codigo: validatedData.codigo, + descripcion: validatedData.descripcion, + unidad: validatedData.unidad, + rendimientoDiario: validatedData.rendimientoDiario, + porcentajeIndirectos, + porcentajeUtilidad, + }, + include: { + insumos: { + include: { + material: true, + categoriaManoObra: true, + equipo: true, + }, + }, + }, + }); + + return NextResponse.json(apu); + } catch (error) { + console.error("Error updating APU:", error); + return NextResponse.json( + { error: "Error al actualizar el APU" }, + { status: 500 } + ); + } +} + +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; + + // Verify ownership + const existing = await prisma.analisisPrecioUnitario.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + include: { + partidas: { take: 1 }, + }, + }); + + if (!existing) { + return NextResponse.json({ error: "APU no encontrado" }, { status: 404 }); + } + + // Check if used in any partida + if (existing.partidas.length > 0) { + return NextResponse.json( + { error: "No se puede eliminar. Este APU esta vinculado a partidas de presupuesto" }, + { status: 400 } + ); + } + + // Delete APU (insumos will cascade delete) + await prisma.analisisPrecioUnitario.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "APU eliminado correctamente" }); + } catch (error) { + console.error("Error deleting APU:", error); + return NextResponse.json( + { error: "Error al eliminar el APU" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/apu/configuracion/route.ts b/src/app/api/apu/configuracion/route.ts new file mode 100644 index 0000000..619d803 --- /dev/null +++ b/src/app/api/apu/configuracion/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { configuracionAPUSchema } from "@/lib/validations/apu"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + let config = await prisma.configuracionAPU.findUnique({ + where: { empresaId: session.user.empresaId }, + }); + + // If no config exists, create default one + if (!config) { + config = await prisma.configuracionAPU.create({ + data: { + empresaId: session.user.empresaId, + porcentajeHerramientaMenor: 3, + porcentajeIndirectos: 8, + porcentajeUtilidad: 10, + }, + }); + } + + return NextResponse.json(config); + } catch (error) { + console.error("Error fetching configuracion APU:", error); + return NextResponse.json( + { error: "Error al obtener la configuracion" }, + { status: 500 } + ); + } +} + +export async function PUT(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 validatedData = configuracionAPUSchema.parse(body); + + const config = await prisma.configuracionAPU.upsert({ + where: { empresaId: session.user.empresaId }, + update: { + porcentajeHerramientaMenor: validatedData.porcentajeHerramientaMenor, + porcentajeIndirectos: validatedData.porcentajeIndirectos, + porcentajeUtilidad: validatedData.porcentajeUtilidad, + }, + create: { + empresaId: session.user.empresaId, + porcentajeHerramientaMenor: validatedData.porcentajeHerramientaMenor, + porcentajeIndirectos: validatedData.porcentajeIndirectos, + porcentajeUtilidad: validatedData.porcentajeUtilidad, + }, + }); + + return NextResponse.json(config); + } catch (error) { + console.error("Error updating configuracion APU:", error); + return NextResponse.json( + { error: "Error al actualizar la configuracion" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/apu/equipos/[id]/route.ts b/src/app/api/apu/equipos/[id]/route.ts new file mode 100644 index 0000000..097a466 --- /dev/null +++ b/src/app/api/apu/equipos/[id]/route.ts @@ -0,0 +1,171 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + equipoMaquinariaSchema, + calcularCostoHorario, +} from "@/lib/validations/apu"; + +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 equipo = await prisma.equipoMaquinaria.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + }); + + if (!equipo) { + return NextResponse.json( + { error: "Equipo no encontrado" }, + { status: 404 } + ); + } + + return NextResponse.json(equipo); + } catch (error) { + console.error("Error fetching equipo:", error); + return NextResponse.json( + { error: "Error al obtener el equipo" }, + { status: 500 } + ); + } +} + +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(); + const validatedData = equipoMaquinariaSchema.partial().parse(body); + + // Verify ownership + const existing = await prisma.equipoMaquinaria.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + }); + + if (!existing) { + return NextResponse.json( + { error: "Equipo no encontrado" }, + { status: 404 } + ); + } + + // If changing codigo, check for duplicates + if (validatedData.codigo && validatedData.codigo !== existing.codigo) { + const duplicate = await prisma.equipoMaquinaria.findFirst({ + where: { + codigo: validatedData.codigo, + empresaId: session.user.empresaId, + NOT: { id }, + }, + }); + + if (duplicate) { + return NextResponse.json( + { error: "Ya existe un equipo con ese codigo" }, + { status: 400 } + ); + } + } + + // Recalculate costo horario + const costoHorario = calcularCostoHorario({ + valorAdquisicion: validatedData.valorAdquisicion ?? existing.valorAdquisicion, + vidaUtilHoras: validatedData.vidaUtilHoras ?? existing.vidaUtilHoras, + valorRescate: validatedData.valorRescate ?? existing.valorRescate, + consumoCombustible: validatedData.consumoCombustible ?? existing.consumoCombustible, + precioCombustible: validatedData.precioCombustible ?? existing.precioCombustible, + factorMantenimiento: validatedData.factorMantenimiento ?? existing.factorMantenimiento, + costoOperador: validatedData.costoOperador ?? existing.costoOperador, + }); + + const equipo = await prisma.equipoMaquinaria.update({ + where: { id }, + data: { + ...validatedData, + costoHorario, + }, + }); + + return NextResponse.json(equipo); + } catch (error) { + console.error("Error updating equipo:", error); + return NextResponse.json( + { error: "Error al actualizar el equipo" }, + { status: 500 } + ); + } +} + +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; + + // Verify ownership + const existing = await prisma.equipoMaquinaria.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + include: { + insumosAPU: { take: 1 }, + }, + }); + + if (!existing) { + return NextResponse.json( + { error: "Equipo no encontrado" }, + { status: 404 } + ); + } + + // Check if used in any APU + if (existing.insumosAPU.length > 0) { + return NextResponse.json( + { error: "No se puede eliminar. Este equipo esta en uso en uno o mas APUs" }, + { status: 400 } + ); + } + + await prisma.equipoMaquinaria.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Equipo eliminado correctamente" }); + } catch (error) { + console.error("Error deleting equipo:", error); + return NextResponse.json( + { error: "Error al eliminar el equipo" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/apu/equipos/route.ts b/src/app/api/apu/equipos/route.ts new file mode 100644 index 0000000..f5ab6ce --- /dev/null +++ b/src/app/api/apu/equipos/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + equipoMaquinariaSchema, + calcularCostoHorario, +} from "@/lib/validations/apu"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const equipos = await prisma.equipoMaquinaria.findMany({ + where: { empresaId: session.user.empresaId }, + orderBy: { nombre: "asc" }, + }); + + return NextResponse.json(equipos); + } catch (error) { + console.error("Error fetching equipos:", error); + return NextResponse.json( + { error: "Error al obtener los equipos" }, + { status: 500 } + ); + } +} + +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 validatedData = equipoMaquinariaSchema.parse(body); + + // Check if codigo already exists + const existing = await prisma.equipoMaquinaria.findFirst({ + where: { + codigo: validatedData.codigo, + empresaId: session.user.empresaId, + }, + }); + + if (existing) { + return NextResponse.json( + { error: "Ya existe un equipo con ese codigo" }, + { status: 400 } + ); + } + + // Calculate costo horario + const costoHorario = calcularCostoHorario({ + valorAdquisicion: validatedData.valorAdquisicion, + vidaUtilHoras: validatedData.vidaUtilHoras, + valorRescate: validatedData.valorRescate ?? 0, + consumoCombustible: validatedData.consumoCombustible, + precioCombustible: validatedData.precioCombustible, + factorMantenimiento: validatedData.factorMantenimiento ?? 0.60, + costoOperador: validatedData.costoOperador, + }); + + const equipo = await prisma.equipoMaquinaria.create({ + data: { + codigo: validatedData.codigo, + nombre: validatedData.nombre, + tipo: validatedData.tipo, + valorAdquisicion: validatedData.valorAdquisicion, + vidaUtilHoras: validatedData.vidaUtilHoras, + valorRescate: validatedData.valorRescate ?? 0, + consumoCombustible: validatedData.consumoCombustible, + precioCombustible: validatedData.precioCombustible, + factorMantenimiento: validatedData.factorMantenimiento ?? 0.60, + costoOperador: validatedData.costoOperador, + costoHorario, + empresaId: session.user.empresaId, + }, + }); + + return NextResponse.json(equipo, { status: 201 }); + } catch (error) { + console.error("Error creating equipo:", error); + return NextResponse.json( + { error: "Error al crear el equipo" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/apu/mano-obra/[id]/route.ts b/src/app/api/apu/mano-obra/[id]/route.ts new file mode 100644 index 0000000..9d60ee5 --- /dev/null +++ b/src/app/api/apu/mano-obra/[id]/route.ts @@ -0,0 +1,174 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + categoriaManoObraSchema, + calcularFSR, +} from "@/lib/validations/apu"; + +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 categoria = await prisma.categoriaTrabajoAPU.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + }); + + if (!categoria) { + return NextResponse.json( + { error: "Categoria no encontrada" }, + { status: 404 } + ); + } + + return NextResponse.json(categoria); + } catch (error) { + console.error("Error fetching categoria:", error); + return NextResponse.json( + { error: "Error al obtener la categoria" }, + { status: 500 } + ); + } +} + +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(); + const validatedData = categoriaManoObraSchema.partial().parse(body); + + // Verify ownership + const existing = await prisma.categoriaTrabajoAPU.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + }); + + if (!existing) { + return NextResponse.json( + { error: "Categoria no encontrada" }, + { status: 404 } + ); + } + + // If changing codigo, check for duplicates + if (validatedData.codigo && validatedData.codigo !== existing.codigo) { + const duplicate = await prisma.categoriaTrabajoAPU.findFirst({ + where: { + codigo: validatedData.codigo, + empresaId: session.user.empresaId, + NOT: { id }, + }, + }); + + if (duplicate) { + return NextResponse.json( + { error: "Ya existe una categoria con ese codigo" }, + { status: 400 } + ); + } + } + + // Recalculate FSR if any factor changed + const factorSalarioReal = calcularFSR({ + factorIMSS: validatedData.factorIMSS ?? existing.factorIMSS, + factorINFONAVIT: validatedData.factorINFONAVIT ?? existing.factorINFONAVIT, + factorRetiro: validatedData.factorRetiro ?? existing.factorRetiro, + factorVacaciones: validatedData.factorVacaciones ?? existing.factorVacaciones, + factorPrimaVac: validatedData.factorPrimaVac ?? existing.factorPrimaVac, + factorAguinaldo: validatedData.factorAguinaldo ?? existing.factorAguinaldo, + }); + + const salarioDiario = validatedData.salarioDiario ?? existing.salarioDiario; + const salarioReal = salarioDiario * factorSalarioReal; + + const categoria = await prisma.categoriaTrabajoAPU.update({ + where: { id }, + data: { + ...validatedData, + factorSalarioReal, + salarioReal, + }, + }); + + return NextResponse.json(categoria); + } catch (error) { + console.error("Error updating categoria:", error); + return NextResponse.json( + { error: "Error al actualizar la categoria" }, + { status: 500 } + ); + } +} + +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; + + // Verify ownership + const existing = await prisma.categoriaTrabajoAPU.findFirst({ + where: { + id, + empresaId: session.user.empresaId, + }, + include: { + insumosAPU: { take: 1 }, + }, + }); + + if (!existing) { + return NextResponse.json( + { error: "Categoria no encontrada" }, + { status: 404 } + ); + } + + // Check if used in any APU + if (existing.insumosAPU.length > 0) { + return NextResponse.json( + { error: "No se puede eliminar. Esta categoria esta en uso en uno o mas APUs" }, + { status: 400 } + ); + } + + await prisma.categoriaTrabajoAPU.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Categoria eliminada correctamente" }); + } catch (error) { + console.error("Error deleting categoria:", error); + return NextResponse.json( + { error: "Error al eliminar la categoria" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/apu/mano-obra/route.ts b/src/app/api/apu/mano-obra/route.ts new file mode 100644 index 0000000..22c0277 --- /dev/null +++ b/src/app/api/apu/mano-obra/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + categoriaManoObraSchema, + calcularFSR, +} from "@/lib/validations/apu"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const categorias = await prisma.categoriaTrabajoAPU.findMany({ + where: { empresaId: session.user.empresaId }, + orderBy: { categoria: "asc" }, + }); + + return NextResponse.json(categorias); + } catch (error) { + console.error("Error fetching categorias mano de obra:", error); + return NextResponse.json( + { error: "Error al obtener las categorias de mano de obra" }, + { status: 500 } + ); + } +} + +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 validatedData = categoriaManoObraSchema.parse(body); + + // Check if codigo already exists + const existing = await prisma.categoriaTrabajoAPU.findFirst({ + where: { + codigo: validatedData.codigo, + empresaId: session.user.empresaId, + }, + }); + + if (existing) { + return NextResponse.json( + { error: "Ya existe una categoria con ese codigo" }, + { status: 400 } + ); + } + + // Calculate FSR and salario real + const factorSalarioReal = calcularFSR({ + factorIMSS: validatedData.factorIMSS ?? 0.2675, + factorINFONAVIT: validatedData.factorINFONAVIT ?? 0.05, + factorRetiro: validatedData.factorRetiro ?? 0.02, + factorVacaciones: validatedData.factorVacaciones ?? 0.0411, + factorPrimaVac: validatedData.factorPrimaVac ?? 0.0103, + factorAguinaldo: validatedData.factorAguinaldo ?? 0.0411, + }); + + const salarioReal = validatedData.salarioDiario * factorSalarioReal; + + const categoria = await prisma.categoriaTrabajoAPU.create({ + data: { + codigo: validatedData.codigo, + nombre: validatedData.nombre, + categoria: validatedData.categoria, + salarioDiario: validatedData.salarioDiario, + factorIMSS: validatedData.factorIMSS ?? 0.2675, + factorINFONAVIT: validatedData.factorINFONAVIT ?? 0.05, + factorRetiro: validatedData.factorRetiro ?? 0.02, + factorVacaciones: validatedData.factorVacaciones ?? 0.0411, + factorPrimaVac: validatedData.factorPrimaVac ?? 0.0103, + factorAguinaldo: validatedData.factorAguinaldo ?? 0.0411, + factorSalarioReal, + salarioReal, + empresaId: session.user.empresaId, + }, + }); + + return NextResponse.json(categoria, { status: 201 }); + } catch (error) { + console.error("Error creating categoria mano de obra:", error); + return NextResponse.json( + { error: "Error al crear la categoria de mano de obra" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/apu/route.ts b/src/app/api/apu/route.ts new file mode 100644 index 0000000..1cb9d0f --- /dev/null +++ b/src/app/api/apu/route.ts @@ -0,0 +1,219 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + apuSchema, + calcularImporteInsumo, + calcularTotalesAPU, +} from "@/lib/validations/apu"; + +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 activo = searchParams.get("activo"); + const search = searchParams.get("search"); + + const where: { + empresaId: string; + activo?: boolean; + OR?: Array<{ codigo?: { contains: string; mode: "insensitive" }; descripcion?: { contains: string; mode: "insensitive" } }>; + } = { + empresaId: session.user.empresaId, + }; + + if (activo !== null) { + where.activo = activo === "true"; + } + + if (search) { + where.OR = [ + { codigo: { contains: search, mode: "insensitive" } }, + { descripcion: { contains: search, mode: "insensitive" } }, + ]; + } + + const apus = await prisma.analisisPrecioUnitario.findMany({ + where, + include: { + insumos: { + include: { + material: true, + categoriaManoObra: true, + equipo: true, + }, + }, + _count: { + select: { partidas: true }, + }, + }, + orderBy: { codigo: "asc" }, + }); + + return NextResponse.json(apus); + } catch (error) { + console.error("Error fetching APUs:", error); + return NextResponse.json( + { error: "Error al obtener los APUs" }, + { status: 500 } + ); + } +} + +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 validatedData = apuSchema.parse(body); + + // Check if codigo already exists + const existing = await prisma.analisisPrecioUnitario.findFirst({ + where: { + codigo: validatedData.codigo, + empresaId: session.user.empresaId, + }, + }); + + if (existing) { + return NextResponse.json( + { error: "Ya existe un APU con ese codigo" }, + { status: 400 } + ); + } + + // Get configuration for calculations + let config = await prisma.configuracionAPU.findUnique({ + where: { empresaId: session.user.empresaId }, + }); + + if (!config) { + config = await prisma.configuracionAPU.create({ + data: { + empresaId: session.user.empresaId, + porcentajeHerramientaMenor: 3, + porcentajeIndirectos: 8, + porcentajeUtilidad: 10, + }, + }); + } + + // Use provided percentages or defaults from config + const porcentajeIndirectos = validatedData.porcentajeIndirectos ?? config.porcentajeIndirectos; + const porcentajeUtilidad = validatedData.porcentajeUtilidad ?? config.porcentajeUtilidad; + + // Process insumos and calculate importes + const insumosConCalculos = await Promise.all( + validatedData.insumos.map(async (insumo) => { + let precioUnitario = insumo.precioUnitario; + + // Get price from catalog if linked + if (insumo.materialId) { + const material = await prisma.material.findUnique({ + where: { id: insumo.materialId }, + }); + if (material) { + precioUnitario = material.precioUnitario; + } + } else if (insumo.categoriaManoObraId) { + const categoria = await prisma.categoriaTrabajoAPU.findUnique({ + where: { id: insumo.categoriaManoObraId }, + }); + if (categoria) { + precioUnitario = categoria.salarioReal; + } + } else if (insumo.equipoId) { + const equipo = await prisma.equipoMaquinaria.findUnique({ + where: { id: insumo.equipoId }, + }); + if (equipo) { + precioUnitario = equipo.costoHorario; + } + } + + const { cantidadConDesperdicio, importe } = calcularImporteInsumo({ + tipo: insumo.tipo, + cantidad: insumo.cantidad, + desperdicio: insumo.desperdicio ?? 0, + rendimiento: insumo.rendimiento, + precioUnitario, + }); + + return { + ...insumo, + precioUnitario, + cantidadConDesperdicio, + importe, + }; + }) + ); + + // Calculate totals + const totales = calcularTotalesAPU(insumosConCalculos, { + porcentajeHerramientaMenor: config.porcentajeHerramientaMenor, + porcentajeIndirectos, + porcentajeUtilidad, + }); + + // Create APU with insumos in a transaction + const apu = await prisma.analisisPrecioUnitario.create({ + data: { + codigo: validatedData.codigo, + descripcion: validatedData.descripcion, + unidad: validatedData.unidad, + rendimientoDiario: validatedData.rendimientoDiario, + costoMateriales: totales.costoMateriales, + costoManoObra: totales.costoManoObra, + costoEquipo: totales.costoEquipo, + costoHerramienta: totales.costoHerramienta, + costoDirecto: totales.costoDirecto, + porcentajeIndirectos, + costoIndirectos: totales.costoIndirectos, + porcentajeUtilidad, + costoUtilidad: totales.costoUtilidad, + precioUnitario: totales.precioUnitario, + empresaId: session.user.empresaId, + insumos: { + create: insumosConCalculos.map((insumo) => ({ + tipo: insumo.tipo, + descripcion: insumo.descripcion, + unidad: insumo.unidad, + cantidad: insumo.cantidad, + desperdicio: insumo.desperdicio ?? 0, + cantidadConDesperdicio: insumo.cantidadConDesperdicio, + rendimiento: insumo.rendimiento, + precioUnitario: insumo.precioUnitario, + importe: insumo.importe, + materialId: insumo.materialId, + categoriaManoObraId: insumo.categoriaManoObraId, + equipoId: insumo.equipoId, + })), + }, + }, + include: { + insumos: { + include: { + material: true, + categoriaManoObra: true, + equipo: true, + }, + }, + }, + }); + + return NextResponse.json(apu, { status: 201 }); + } catch (error) { + console.error("Error creating APU:", error); + return NextResponse.json( + { error: "Error al crear el APU" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/presupuestos/[id]/explosion/route.ts b/src/app/api/presupuestos/[id]/explosion/route.ts new file mode 100644 index 0000000..d640186 --- /dev/null +++ b/src/app/api/presupuestos/[id]/explosion/route.ts @@ -0,0 +1,184 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { TipoInsumoAPU, UnidadMedida } from "@prisma/client"; + +interface InsumoConsolidado { + tipo: TipoInsumoAPU; + descripcion: string; + unidad: UnidadMedida; + cantidadTotal: number; + precioUnitario: number; + importeTotal: number; + materialId?: string | null; + categoriaManoObraId?: string | null; + equipoId?: string | null; + codigo?: string; + partidasCount: number; +} + +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; + + // Verify presupuesto belongs to empresa + const presupuesto = await prisma.presupuesto.findFirst({ + where: { + id, + obra: { empresaId: session.user.empresaId }, + }, + include: { + obra: { + select: { id: true, nombre: true }, + }, + partidas: { + where: { + apuId: { not: null }, + }, + include: { + apu: { + include: { + insumos: { + include: { + material: { + select: { id: true, codigo: true, nombre: true }, + }, + categoriaManoObra: { + select: { id: true, codigo: true, nombre: true }, + }, + equipo: { + select: { id: true, codigo: true, nombre: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!presupuesto) { + return NextResponse.json( + { error: "Presupuesto no encontrado" }, + { status: 404 } + ); + } + + // Consolidate insumos from all partidas + const insumosMap = new Map(); + + for (const partida of presupuesto.partidas) { + if (!partida.apu) continue; + + for (const insumo of partida.apu.insumos) { + // Create unique key based on insumo type and reference + const key = insumo.materialId + ? `MATERIAL-${insumo.materialId}` + : insumo.categoriaManoObraId + ? `MANO_OBRA-${insumo.categoriaManoObraId}` + : insumo.equipoId + ? `EQUIPO-${insumo.equipoId}` + : `${insumo.tipo}-${insumo.descripcion}`; + + // Calculate quantity needed for this partida + // For materials: cantidadPartida * cantidadInsumo * (1 + desperdicio/100) + // For mano de obra: cantidadPartida / rendimiento (jornadas needed) + // For equipo: cantidadPartida * horas por unidad + let cantidadNecesaria: number; + + if (insumo.tipo === "MANO_OBRA" && insumo.rendimiento && insumo.rendimiento > 0) { + // Jornadas necesarias = cantidad de trabajo / rendimiento + cantidadNecesaria = partida.cantidad / insumo.rendimiento; + } else { + // Cantidad = cantidad partida * cantidad insumo por unidad * (1 + desperdicio) + cantidadNecesaria = partida.cantidad * insumo.cantidadConDesperdicio; + } + + const importeNecesario = cantidadNecesaria * insumo.precioUnitario; + + if (insumosMap.has(key)) { + const existing = insumosMap.get(key)!; + existing.cantidadTotal += cantidadNecesaria; + existing.importeTotal += importeNecesario; + existing.partidasCount += 1; + } else { + const codigo = insumo.material?.codigo + || insumo.categoriaManoObra?.codigo + || insumo.equipo?.codigo + || undefined; + + insumosMap.set(key, { + tipo: insumo.tipo, + descripcion: insumo.descripcion, + unidad: insumo.unidad, + cantidadTotal: cantidadNecesaria, + precioUnitario: insumo.precioUnitario, + importeTotal: importeNecesario, + materialId: insumo.materialId, + categoriaManoObraId: insumo.categoriaManoObraId, + equipoId: insumo.equipoId, + codigo, + partidasCount: 1, + }); + } + } + } + + // Convert map to array and group by type + const insumos = Array.from(insumosMap.values()); + + const materiales = insumos + .filter((i) => i.tipo === "MATERIAL") + .sort((a, b) => a.descripcion.localeCompare(b.descripcion)); + + const manoObra = insumos + .filter((i) => i.tipo === "MANO_OBRA") + .sort((a, b) => a.descripcion.localeCompare(b.descripcion)); + + const equipos = insumos + .filter((i) => i.tipo === "EQUIPO") + .sort((a, b) => a.descripcion.localeCompare(b.descripcion)); + + // Calculate totals + const totalMateriales = materiales.reduce((sum, i) => sum + i.importeTotal, 0); + const totalManoObra = manoObra.reduce((sum, i) => sum + i.importeTotal, 0); + const totalEquipos = equipos.reduce((sum, i) => sum + i.importeTotal, 0); + const totalGeneral = totalMateriales + totalManoObra + totalEquipos; + + return NextResponse.json({ + presupuesto: { + id: presupuesto.id, + nombre: presupuesto.nombre, + total: presupuesto.total, + obra: presupuesto.obra, + }, + partidasConAPU: presupuesto.partidas.length, + explosion: { + materiales, + manoObra, + equipos, + }, + totales: { + materiales: totalMateriales, + manoObra: totalManoObra, + equipos: totalEquipos, + total: totalGeneral, + }, + }); + } catch (error) { + console.error("Error generating explosion:", error); + return NextResponse.json( + { error: "Error al generar la explosion de insumos" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/presupuestos/[id]/partidas/[partidaId]/route.ts b/src/app/api/presupuestos/[id]/partidas/[partidaId]/route.ts new file mode 100644 index 0000000..14aec7a --- /dev/null +++ b/src/app/api/presupuestos/[id]/partidas/[partidaId]/route.ts @@ -0,0 +1,228 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { partidaPresupuestoSchema } from "@/lib/validations"; +import { z } from "zod"; + +const partidaUpdateSchema = partidaPresupuestoSchema.partial().extend({ + apuId: z.string().optional().nullable(), +}); + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string; partidaId: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id, partidaId } = await params; + + const partida = await prisma.partidaPresupuesto.findFirst({ + where: { + id: partidaId, + presupuestoId: id, + presupuesto: { + obra: { empresaId: session.user.empresaId }, + }, + }, + include: { + apu: { + include: { + insumos: { + include: { + material: true, + categoriaManoObra: true, + equipo: true, + }, + }, + }, + }, + }, + }); + + if (!partida) { + return NextResponse.json( + { error: "Partida no encontrada" }, + { status: 404 } + ); + } + + return NextResponse.json(partida); + } catch (error) { + console.error("Error fetching partida:", error); + return NextResponse.json( + { error: "Error al obtener la partida" }, + { status: 500 } + ); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string; partidaId: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id, partidaId } = await params; + const body = await request.json(); + const validatedData = partidaUpdateSchema.parse(body); + + // Verify partida exists and belongs to empresa + const existingPartida = await prisma.partidaPresupuesto.findFirst({ + where: { + id: partidaId, + presupuestoId: id, + presupuesto: { + obra: { empresaId: session.user.empresaId }, + }, + }, + }); + + if (!existingPartida) { + return NextResponse.json( + { error: "Partida no encontrada" }, + { status: 404 } + ); + } + + // If APU is provided, get price from it + let precioUnitario = validatedData.precioUnitario ?? existingPartida.precioUnitario; + + if (validatedData.apuId) { + const apu = await prisma.analisisPrecioUnitario.findFirst({ + where: { + id: validatedData.apuId, + empresaId: session.user.empresaId, + }, + }); + + if (apu) { + precioUnitario = apu.precioUnitario; + } + } + + const cantidad = validatedData.cantidad ?? existingPartida.cantidad; + const total = cantidad * precioUnitario; + + const partida = await prisma.partidaPresupuesto.update({ + where: { id: partidaId }, + data: { + codigo: validatedData.codigo, + descripcion: validatedData.descripcion, + unidad: validatedData.unidad, + cantidad: validatedData.cantidad, + precioUnitario, + total, + categoria: validatedData.categoria, + apuId: validatedData.apuId, + }, + include: { + apu: { + select: { + id: true, + codigo: true, + descripcion: true, + precioUnitario: true, + }, + }, + }, + }); + + // Update presupuesto total + await updatePresupuestoTotal(id); + + return NextResponse.json(partida); + } catch (error) { + console.error("Error updating partida:", error); + return NextResponse.json( + { error: "Error al actualizar la partida" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; partidaId: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id, partidaId } = await params; + + // Verify partida exists and belongs to empresa + const existingPartida = await prisma.partidaPresupuesto.findFirst({ + where: { + id: partidaId, + presupuestoId: id, + presupuesto: { + obra: { empresaId: session.user.empresaId }, + }, + }, + }); + + if (!existingPartida) { + return NextResponse.json( + { error: "Partida no encontrada" }, + { status: 404 } + ); + } + + await prisma.partidaPresupuesto.delete({ + where: { id: partidaId }, + }); + + // Update presupuesto total + await updatePresupuestoTotal(id); + + return NextResponse.json({ message: "Partida eliminada correctamente" }); + } catch (error) { + console.error("Error deleting partida:", error); + return NextResponse.json( + { error: "Error al eliminar la partida" }, + { status: 500 } + ); + } +} + +async function updatePresupuestoTotal(presupuestoId: string) { + const result = await prisma.partidaPresupuesto.aggregate({ + where: { presupuestoId }, + _sum: { total: true }, + }); + + const total = result._sum.total || 0; + + await prisma.presupuesto.update({ + where: { id: presupuestoId }, + data: { total }, + }); + + // Also update obra's presupuestoTotal if this presupuesto is approved + const presupuesto = await prisma.presupuesto.findUnique({ + where: { id: presupuestoId }, + select: { obraId: true, aprobado: true }, + }); + + if (presupuesto?.aprobado) { + const obraTotal = await prisma.presupuesto.aggregate({ + where: { obraId: presupuesto.obraId, aprobado: true }, + _sum: { total: true }, + }); + + await prisma.obra.update({ + where: { id: presupuesto.obraId }, + data: { presupuestoTotal: obraTotal._sum.total || 0 }, + }); + } +} diff --git a/src/app/api/presupuestos/[id]/partidas/route.ts b/src/app/api/presupuestos/[id]/partidas/route.ts new file mode 100644 index 0000000..90f3305 --- /dev/null +++ b/src/app/api/presupuestos/[id]/partidas/route.ts @@ -0,0 +1,186 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { partidaPresupuestoSchema } from "@/lib/validations"; +import { z } from "zod"; + +// Extended schema for partida with APU support +const partidaWithAPUSchema = partidaPresupuestoSchema.extend({ + apuId: z.string().optional(), +}); + +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; + + // Verify presupuesto belongs to empresa + const presupuesto = await prisma.presupuesto.findFirst({ + where: { + id, + obra: { empresaId: session.user.empresaId }, + }, + }); + + if (!presupuesto) { + return NextResponse.json( + { error: "Presupuesto no encontrado" }, + { status: 404 } + ); + } + + const partidas = await prisma.partidaPresupuesto.findMany({ + where: { presupuestoId: id }, + include: { + apu: { + select: { + id: true, + codigo: true, + descripcion: true, + unidad: true, + precioUnitario: true, + costoMateriales: true, + costoManoObra: true, + costoEquipo: true, + costoHerramienta: true, + }, + }, + }, + orderBy: { codigo: "asc" }, + }); + + return NextResponse.json(partidas); + } catch (error) { + console.error("Error fetching partidas:", error); + return NextResponse.json( + { error: "Error al obtener las partidas" }, + { status: 500 } + ); + } +} + +export async function POST( + 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(); + + // Override presupuestoId from URL + const dataWithPresupuesto = { ...body, presupuestoId: id }; + const validatedData = partidaWithAPUSchema.parse(dataWithPresupuesto); + + // Verify presupuesto belongs to empresa + const presupuesto = await prisma.presupuesto.findFirst({ + where: { + id, + obra: { empresaId: session.user.empresaId }, + }, + }); + + if (!presupuesto) { + return NextResponse.json( + { error: "Presupuesto no encontrado" }, + { status: 404 } + ); + } + + // If APU is provided, get price from it + let precioUnitario = validatedData.precioUnitario; + if (validatedData.apuId) { + const apu = await prisma.analisisPrecioUnitario.findFirst({ + where: { + id: validatedData.apuId, + empresaId: session.user.empresaId, + }, + }); + + if (apu) { + precioUnitario = apu.precioUnitario; + } + } + + const total = validatedData.cantidad * precioUnitario; + + const partida = await prisma.partidaPresupuesto.create({ + data: { + codigo: validatedData.codigo, + descripcion: validatedData.descripcion, + unidad: validatedData.unidad, + cantidad: validatedData.cantidad, + precioUnitario, + total, + categoria: validatedData.categoria, + presupuestoId: id, + apuId: validatedData.apuId || null, + }, + include: { + apu: { + select: { + id: true, + codigo: true, + descripcion: true, + precioUnitario: true, + }, + }, + }, + }); + + // Update presupuesto total + await updatePresupuestoTotal(id); + + return NextResponse.json(partida, { status: 201 }); + } catch (error) { + console.error("Error creating partida:", error); + return NextResponse.json( + { error: "Error al crear la partida" }, + { status: 500 } + ); + } +} + +async function updatePresupuestoTotal(presupuestoId: string) { + const result = await prisma.partidaPresupuesto.aggregate({ + where: { presupuestoId }, + _sum: { total: true }, + }); + + const total = result._sum.total || 0; + + await prisma.presupuesto.update({ + where: { id: presupuestoId }, + data: { total }, + }); + + // Also update obra's presupuestoTotal if this presupuesto is approved + const presupuesto = await prisma.presupuesto.findUnique({ + where: { id: presupuestoId }, + select: { obraId: true, aprobado: true }, + }); + + if (presupuesto?.aprobado) { + // Sum all approved presupuestos for this obra + const obraTotal = await prisma.presupuesto.aggregate({ + where: { obraId: presupuesto.obraId, aprobado: true }, + _sum: { total: true }, + }); + + await prisma.obra.update({ + where: { id: presupuesto.obraId }, + data: { presupuestoTotal: obraTotal._sum.total || 0 }, + }); + } +} diff --git a/src/app/api/presupuestos/route.ts b/src/app/api/presupuestos/route.ts new file mode 100644 index 0000000..44cabf4 --- /dev/null +++ b/src/app/api/presupuestos/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { presupuestoSchema } from "@/lib/validations"; + +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 where: { obra: { empresaId: string }; obraId?: string } = { + obra: { empresaId: session.user.empresaId }, + }; + + if (obraId) { + where.obraId = obraId; + } + + const presupuestos = await prisma.presupuesto.findMany({ + where, + include: { + obra: { + select: { id: true, nombre: true }, + }, + partidas: { + include: { + apu: { + select: { id: true, codigo: true, descripcion: true }, + }, + }, + orderBy: { codigo: "asc" }, + }, + _count: { + select: { partidas: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(presupuestos); + } catch (error) { + console.error("Error fetching presupuestos:", error); + return NextResponse.json( + { error: "Error al obtener los presupuestos" }, + { status: 500 } + ); + } +} + +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 validatedData = presupuestoSchema.parse(body); + + // Verify obra belongs to empresa + const obra = await prisma.obra.findFirst({ + where: { + id: validatedData.obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + const presupuesto = await prisma.presupuesto.create({ + data: { + nombre: validatedData.nombre, + descripcion: validatedData.descripcion, + obraId: validatedData.obraId, + }, + include: { + partidas: true, + }, + }); + + return NextResponse.json(presupuesto, { status: 201 }); + } catch (error) { + console.error("Error creating presupuesto:", error); + return NextResponse.json( + { error: "Error al crear el presupuesto" }, + { status: 500 } + ); + } +} diff --git a/src/components/apu/apu-detail.tsx b/src/components/apu/apu-detail.tsx new file mode 100644 index 0000000..7d08a89 --- /dev/null +++ b/src/components/apu/apu-detail.tsx @@ -0,0 +1,690 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +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, +} from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "@/hooks/use-toast"; +import { + ArrowLeft, + Pencil, + Trash2, + Package, + Users, + Wrench, + Calculator, + LinkIcon, + Copy, + Loader2, + AlertTriangle, + RefreshCw, +} from "lucide-react"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + UNIDAD_MEDIDA_LABELS, + TIPO_INSUMO_APU_LABELS, + TIPO_INSUMO_APU_COLORS, +} from "@/types"; +import { UnidadMedida, TipoInsumoAPU } from "@prisma/client"; +import { ExportPDFButton, APUPDF } from "@/components/pdf"; + +interface Insumo { + id: string; + tipo: TipoInsumoAPU; + descripcion: string; + unidad: UnidadMedida; + cantidad: number; + desperdicio: number; + cantidadConDesperdicio: number; + rendimiento: number | null; + precioUnitario: number; + importe: number; + material: { codigo: string; nombre: string; precioUnitario: number } | null; + categoriaManoObra: { codigo: string; nombre: string; salarioReal: number } | null; + equipo: { codigo: string; nombre: string; costoHorario: number } | null; +} + +interface Partida { + id: string; + codigo: string; + descripcion: string; + presupuesto: { + id: string; + nombre: string; + obra: { + id: string; + nombre: string; + }; + }; +} + +interface APU { + id: string; + codigo: string; + descripcion: string; + unidad: UnidadMedida; + rendimientoDiario: number | null; + costoMateriales: number; + costoManoObra: number; + costoEquipo: number; + costoHerramienta: number; + costoDirecto: number; + porcentajeIndirectos: number; + costoIndirectos: number; + porcentajeUtilidad: number; + costoUtilidad: number; + precioUnitario: number; + activo: boolean; + insumos: Insumo[]; + partidas: Partida[]; +} + +interface APUDetailProps { + apu: APU; +} + +export function APUDetail({ apu }: APUDetailProps) { + const router = useRouter(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDuplicating, setIsDuplicating] = useState(false); + + const handleDuplicate = async () => { + setIsDuplicating(true); + try { + const response = await fetch(`/api/apu/${apu.id}/duplicate`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al duplicar"); + } + + const newApu = await response.json(); + + toast({ + title: "APU duplicado", + description: `Se ha creado una copia: ${newApu.codigo}`, + }); + + router.push(`/apu/${newApu.id}`); + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo duplicar el APU", + variant: "destructive", + }); + } finally { + setIsDuplicating(false); + } + }; + + const materialesInsumos = apu.insumos.filter((i) => i.tipo === "MATERIAL"); + const manoObraInsumos = apu.insumos.filter((i) => i.tipo === "MANO_OBRA"); + const equipoInsumos = apu.insumos.filter((i) => i.tipo === "EQUIPO"); + + // Check for outdated prices + const outdatedPrices = apu.insumos.filter((insumo) => { + if (insumo.material) { + return Math.abs(insumo.precioUnitario - insumo.material.precioUnitario) > 0.01; + } + if (insumo.categoriaManoObra) { + return Math.abs(insumo.precioUnitario - insumo.categoriaManoObra.salarioReal) > 0.01; + } + if (insumo.equipo) { + return Math.abs(insumo.precioUnitario - insumo.equipo.costoHorario) > 0.01; + } + return false; + }); + + const hasOutdatedPrices = outdatedPrices.length > 0; + + const getCurrentPrice = (insumo: Insumo): number | null => { + if (insumo.material) return insumo.material.precioUnitario; + if (insumo.categoriaManoObra) return insumo.categoriaManoObra.salarioReal; + if (insumo.equipo) return insumo.equipo.costoHorario; + return null; + }; + + const isPriceOutdated = (insumo: Insumo): boolean => { + const currentPrice = getCurrentPrice(insumo); + if (currentPrice === null) return false; + return Math.abs(insumo.precioUnitario - currentPrice) > 0.01; + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + const response = await fetch(`/api/apu/${apu.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al eliminar"); + } + + toast({ + title: "APU eliminado", + description: "El analisis de precio unitario ha sido eliminado", + }); + + router.push("/apu"); + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo eliminar el APU", + variant: "destructive", + }); + } finally { + setIsDeleting(false); + setShowDeleteDialog(false); + } + }; + + return ( +
+
+
+ +
+
+

{apu.codigo}

+ + {apu.activo ? "Activo" : "Inactivo"} + +
+

{apu.descripcion}

+
+
+
+ } + fileName={`apu-${apu.codigo.toLowerCase().replace(/\s+/g, "-")}`} + > + Exportar PDF + + + + +
+
+ + {/* Outdated Prices Alert */} + {hasOutdatedPrices && ( + + + Precios Desactualizados + +

+ {outdatedPrices.length} insumo{outdatedPrices.length !== 1 ? "s tienen" : " tiene"} precios + diferentes al catalogo actual. Los precios marcados con{" "} + estan desactualizados. +

+ +
+
+ )} + +
+ + + + Unidad + + + +

{UNIDAD_MEDIDA_LABELS[apu.unidad]}

+
+
+ + + + Rendimiento Diario + + + +

+ {apu.rendimientoDiario + ? `${apu.rendimientoDiario} ${UNIDAD_MEDIDA_LABELS[apu.unidad]}/jornada` + : "-"} +

+
+
+ + + + Precio Unitario + + + +

+ ${apu.precioUnitario.toFixed(2)} +

+
+
+
+ + {/* Materiales */} + {materialesInsumos.length > 0 && ( + + + + + Materiales + + + + + + + Codigo + Descripcion + Cantidad + Desp. % + Cant. c/Desp. + Unidad + P.U. + Importe + + + + {materialesInsumos.map((insumo) => ( + + + {insumo.material?.codigo || "-"} + + {insumo.descripcion} + + {insumo.cantidad.toFixed(4)} + + + {insumo.desperdicio}% + + + {insumo.cantidadConDesperdicio.toFixed(4)} + + {UNIDAD_MEDIDA_LABELS[insumo.unidad]} + + {isPriceOutdated(insumo) ? ( + + + + + + ${insumo.precioUnitario.toFixed(2)} + + + +

Precio actual: ${getCurrentPrice(insumo)?.toFixed(2)}

+
+
+
+ ) : ( + `$${insumo.precioUnitario.toFixed(2)}` + )} +
+ + ${insumo.importe.toFixed(2)} + +
+ ))} + + + Subtotal Materiales + + + ${apu.costoMateriales.toFixed(2)} + + +
+
+
+
+ )} + + {/* Mano de Obra */} + {manoObraInsumos.length > 0 && ( + + + + + Mano de Obra + + + + + + + Codigo + Descripcion + Cantidad + Rendimiento + Salario Real + Importe + + + + {manoObraInsumos.map((insumo) => ( + + + {insumo.categoriaManoObra?.codigo || "-"} + + {insumo.descripcion} + + {insumo.cantidad} + + + {insumo.rendimiento || "-"} + + + {isPriceOutdated(insumo) ? ( + + + + + + ${insumo.precioUnitario.toFixed(2)} + + + +

Salario actual: ${getCurrentPrice(insumo)?.toFixed(2)}

+
+
+
+ ) : ( + `$${insumo.precioUnitario.toFixed(2)}` + )} +
+ + ${insumo.importe.toFixed(2)} + +
+ ))} + + + Subtotal Mano de Obra + + + ${apu.costoManoObra.toFixed(2)} + + +
+
+
+
+ )} + + {/* Equipo */} + {equipoInsumos.length > 0 && ( + + + + + Equipo y Maquinaria + + + + + + + Codigo + Descripcion + Horas + Costo Horario + Importe + + + + {equipoInsumos.map((insumo) => ( + + + {insumo.equipo?.codigo || "-"} + + {insumo.descripcion} + + {insumo.cantidad} + + + {isPriceOutdated(insumo) ? ( + + + + + + ${insumo.precioUnitario.toFixed(2)} + + + +

Costo horario actual: ${getCurrentPrice(insumo)?.toFixed(2)}

+
+
+
+ ) : ( + `$${insumo.precioUnitario.toFixed(2)}` + )} +
+ + ${insumo.importe.toFixed(2)} + +
+ ))} + + + Subtotal Equipo + + + ${apu.costoEquipo.toFixed(2)} + + +
+
+
+
+ )} + + {/* Resumen de Costos */} + + + + + Resumen de Costos + + + +
+
+
+ Materiales: + ${apu.costoMateriales.toFixed(2)} +
+
+ Mano de Obra: + ${apu.costoManoObra.toFixed(2)} +
+
+ Equipo: + ${apu.costoEquipo.toFixed(2)} +
+
+ Herramienta Menor: + ${apu.costoHerramienta.toFixed(2)} +
+
+ Costo Directo: + ${apu.costoDirecto.toFixed(2)} +
+
+ + Indirectos ({apu.porcentajeIndirectos}%): + + ${apu.costoIndirectos.toFixed(2)} +
+
+ + Utilidad ({apu.porcentajeUtilidad}%): + + ${apu.costoUtilidad.toFixed(2)} +
+
+ Precio Unitario: + + ${apu.precioUnitario.toFixed(2)} + +
+
+
+
+
+ + {/* Partidas Vinculadas */} + {apu.partidas.length > 0 && ( + + + + + Partidas Vinculadas + + + Este APU esta siendo utilizado en las siguientes partidas de presupuesto + + + + + + + Obra + Presupuesto + Codigo Partida + Descripcion + + + + {apu.partidas.map((partida) => ( + + + + {partida.presupuesto.obra.nombre} + + + {partida.presupuesto.nombre} + {partida.codigo} + {partida.descripcion} + + ))} + +
+
+
+ )} + + + + + Eliminar APU + + Esta accion no se puede deshacer. El analisis de precio unitario + sera eliminado permanentemente. + + + + Cancelar + + {isDeleting ? "Eliminando..." : "Eliminar"} + + + + +
+ ); +} diff --git a/src/components/apu/apu-form.tsx b/src/components/apu/apu-form.tsx new file mode 100644 index 0000000..fbde39d --- /dev/null +++ b/src/components/apu/apu-form.tsx @@ -0,0 +1,734 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { + apuSchema, + calcularImporteInsumo, + calcularTotalesAPU, + type APUInput, + type InsumoAPUInput, +} from "@/lib/validations/apu"; +import { toast } from "@/hooks/use-toast"; +import { Loader2, Plus, Trash2, Package, Users, Wrench } from "lucide-react"; +import { + UNIDAD_MEDIDA_LABELS, + TIPO_INSUMO_APU_LABELS, + TIPO_INSUMO_APU_COLORS, +} from "@/types"; +import { UnidadMedida, TipoInsumoAPU } from "@prisma/client"; + +interface Material { + id: string; + codigo: string; + nombre: string; + unidad: UnidadMedida; + precioUnitario: number; +} + +interface CategoriaTrabajoAPU { + id: string; + codigo: string; + nombre: string; + categoria: string; + salarioReal: number; +} + +interface EquipoMaquinaria { + id: string; + codigo: string; + nombre: string; + tipo: string; + costoHorario: number; +} + +interface ConfiguracionAPU { + porcentajeHerramientaMenor: number; + porcentajeIndirectos: number; + porcentajeUtilidad: number; +} + +interface APUFormProps { + apu?: { + id: string; + codigo: string; + descripcion: string; + unidad: UnidadMedida; + rendimientoDiario: number | null; + porcentajeIndirectos: number; + porcentajeUtilidad: number; + insumos: Array<{ + id: string; + tipo: TipoInsumoAPU; + descripcion: string; + unidad: UnidadMedida; + cantidad: number; + desperdicio: number; + rendimiento: number | null; + precioUnitario: number; + materialId: string | null; + categoriaManoObraId: string | null; + equipoId: string | null; + }>; + }; + materiales: Material[]; + categoriasManoObra: CategoriaTrabajoAPU[]; + equipos: EquipoMaquinaria[]; + configuracion: ConfiguracionAPU; +} + +export function APUForm({ + apu, + materiales, + categoriasManoObra, + equipos, + configuracion, +}: APUFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const isEditing = !!apu; + + const defaultInsumos: InsumoAPUInput[] = apu?.insumos.map((i) => ({ + tipo: i.tipo, + descripcion: i.descripcion, + unidad: i.unidad, + cantidad: i.cantidad, + desperdicio: i.desperdicio, + rendimiento: i.rendimiento ?? undefined, + precioUnitario: i.precioUnitario, + materialId: i.materialId ?? undefined, + categoriaManoObraId: i.categoriaManoObraId ?? undefined, + equipoId: i.equipoId ?? undefined, + })) || []; + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + watch, + control, + } = useForm({ + resolver: zodResolver(apuSchema), + defaultValues: { + codigo: apu?.codigo || "", + descripcion: apu?.descripcion || "", + unidad: apu?.unidad || "METRO_CUADRADO", + rendimientoDiario: apu?.rendimientoDiario ?? undefined, + porcentajeIndirectos: apu?.porcentajeIndirectos ?? configuracion.porcentajeIndirectos, + porcentajeUtilidad: apu?.porcentajeUtilidad ?? configuracion.porcentajeUtilidad, + insumos: defaultInsumos, + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "insumos", + }); + + const watchedValues = watch(); + + // Calculate totals in real-time + const calculatedTotals = useMemo(() => { + if (!watchedValues.insumos || watchedValues.insumos.length === 0) { + return { + costoMateriales: 0, + costoManoObra: 0, + costoEquipo: 0, + costoHerramienta: 0, + costoDirecto: 0, + costoIndirectos: 0, + costoUtilidad: 0, + precioUnitario: 0, + }; + } + + const insumosConImporte = watchedValues.insumos.map((insumo) => { + const { importe } = calcularImporteInsumo({ + tipo: insumo.tipo, + cantidad: insumo.cantidad || 0, + desperdicio: insumo.desperdicio || 0, + rendimiento: insumo.rendimiento, + precioUnitario: insumo.precioUnitario || 0, + }); + return { tipo: insumo.tipo, importe }; + }); + + return calcularTotalesAPU(insumosConImporte, { + porcentajeHerramientaMenor: configuracion.porcentajeHerramientaMenor, + porcentajeIndirectos: watchedValues.porcentajeIndirectos || 0, + porcentajeUtilidad: watchedValues.porcentajeUtilidad || 0, + }); + }, [watchedValues.insumos, watchedValues.porcentajeIndirectos, watchedValues.porcentajeUtilidad, configuracion]); + + const addInsumo = (tipo: TipoInsumoAPU) => { + const defaultUnidad = tipo === "MANO_OBRA" ? "JORNADA" : tipo === "EQUIPO" ? "HORA" : "UNIDAD"; + append({ + tipo, + descripcion: "", + unidad: defaultUnidad as UnidadMedida, + cantidad: 1, + desperdicio: tipo === "MATERIAL" ? 5 : 0, + rendimiento: tipo === "MANO_OBRA" ? 1 : undefined, + precioUnitario: 0, + }); + }; + + const handleMaterialSelect = (index: number, materialId: string) => { + const material = materiales.find((m) => m.id === materialId); + if (material) { + setValue(`insumos.${index}.materialId`, materialId); + setValue(`insumos.${index}.descripcion`, material.nombre); + setValue(`insumos.${index}.unidad`, material.unidad); + setValue(`insumos.${index}.precioUnitario`, material.precioUnitario); + } + }; + + const handleManoObraSelect = (index: number, categoriaId: string) => { + const categoria = categoriasManoObra.find((c) => c.id === categoriaId); + if (categoria) { + setValue(`insumos.${index}.categoriaManoObraId`, categoriaId); + setValue(`insumos.${index}.descripcion`, categoria.nombre); + setValue(`insumos.${index}.unidad`, "JORNADA"); + setValue(`insumos.${index}.precioUnitario`, categoria.salarioReal); + } + }; + + const handleEquipoSelect = (index: number, equipoId: string) => { + const equipo = equipos.find((e) => e.id === equipoId); + if (equipo) { + setValue(`insumos.${index}.equipoId`, equipoId); + setValue(`insumos.${index}.descripcion`, equipo.nombre); + setValue(`insumos.${index}.unidad`, "HORA"); + setValue(`insumos.${index}.precioUnitario`, equipo.costoHorario); + } + }; + + const onSubmit = async (data: APUInput) => { + setIsLoading(true); + try { + const url = isEditing ? `/api/apu/${apu.id}` : "/api/apu"; + const method = isEditing ? "PUT" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al guardar"); + } + + const result = await response.json(); + + toast({ + title: isEditing ? "APU actualizado" : "APU creado", + description: isEditing + ? "Los cambios han sido guardados" + : "El analisis de precio unitario ha sido creado exitosamente", + }); + + router.push(`/apu/${result.id}`); + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo guardar el APU", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const materialesInsumos = fields.filter((_, i) => watchedValues.insumos?.[i]?.tipo === "MATERIAL"); + const manoObraInsumos = fields.filter((_, i) => watchedValues.insumos?.[i]?.tipo === "MANO_OBRA"); + const equipoInsumos = fields.filter((_, i) => watchedValues.insumos?.[i]?.tipo === "EQUIPO"); + + return ( +
+ + + Informacion General + + Datos basicos del analisis de precio unitario + + + +
+
+ + + {errors.codigo && ( +

{errors.codigo.message}

+ )} +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + {errors.descripcion && ( +

{errors.descripcion.message}

+ )} +
+
+
+ + + + Desglose de Insumos + + Materiales, mano de obra y equipo que componen el precio unitario + + + + + + + + Materiales ({materialesInsumos.length}) + + + + Mano de Obra ({manoObraInsumos.length}) + + + + Equipo ({equipoInsumos.length}) + + + + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ +
+
+ + {errors.insumos && ( +

+ {errors.insumos.message || "Se requiere al menos un insumo"} +

+ )} +
+
+ + + + Porcentajes de Indirectos y Utilidad + + +
+
+ + +
+
+ + +
+
+
+
+ + + + Resumen de Costos + + +
+
+
+ Materiales: + + ${calculatedTotals.costoMateriales.toFixed(2)} + +
+
+ Mano de Obra: + + ${calculatedTotals.costoManoObra.toFixed(2)} + +
+
+ Equipo: + + ${calculatedTotals.costoEquipo.toFixed(2)} + +
+
+ + Herramienta Menor ({configuracion.porcentajeHerramientaMenor}% M.O.): + + + ${calculatedTotals.costoHerramienta.toFixed(2)} + +
+
+ Costo Directo: + + ${calculatedTotals.costoDirecto.toFixed(2)} + +
+
+ + Indirectos ({watchedValues.porcentajeIndirectos || 0}%): + + + ${calculatedTotals.costoIndirectos.toFixed(2)} + +
+
+ + Utilidad ({watchedValues.porcentajeUtilidad || 0}%): + + + ${calculatedTotals.costoUtilidad.toFixed(2)} + +
+
+ Precio Unitario: + + ${calculatedTotals.precioUnitario.toFixed(2)} + +
+
+
+
+
+ +
+ + +
+
+ ); +} + +interface InsumoTableProps { + fields: { id: string }[]; + watchedValues: APUInput; + register: ReturnType>["register"]; + setValue: ReturnType>["setValue"]; + remove: (index: number) => void; + tipo: TipoInsumoAPU; + catalogo: { id: string; nombre?: string; codigo?: string }[]; + onCatalogoSelect: (index: number, id: string) => void; + catalogoLabel: string; + showRendimiento?: boolean; +} + +function InsumoTable({ + fields, + watchedValues, + register, + setValue, + remove, + tipo, + catalogo, + onCatalogoSelect, + catalogoLabel, + showRendimiento = false, +}: InsumoTableProps) { + const filteredFields = fields + .map((field, index) => ({ field, index })) + .filter(({ index }) => watchedValues.insumos?.[index]?.tipo === tipo); + + if (filteredFields.length === 0) { + return ( +
+ No hay {TIPO_INSUMO_APU_LABELS[tipo].toLowerCase()} agregados +
+ ); + } + + return ( +
+ + + + Catalogo + Descripcion + Cantidad + {tipo === "MATERIAL" && ( + Desp. % + )} + {showRendimiento && ( + Rendimiento + )} + P.U. + Importe + + + + + {filteredFields.map(({ field, index }) => { + const insumo = watchedValues.insumos?.[index]; + const { importe } = calcularImporteInsumo({ + tipo: insumo?.tipo || tipo, + cantidad: insumo?.cantidad || 0, + desperdicio: insumo?.desperdicio || 0, + rendimiento: insumo?.rendimiento, + precioUnitario: insumo?.precioUnitario || 0, + }); + + return ( + + + + + + + + + + + {tipo === "MATERIAL" && ( + + + + )} + {showRendimiento && ( + + + + )} + + + + + ${importe.toFixed(2)} + + + + + + ); + })} + +
+
+ ); +} diff --git a/src/components/apu/apu-list.tsx b/src/components/apu/apu-list.tsx new file mode 100644 index 0000000..5bdb7bf --- /dev/null +++ b/src/components/apu/apu-list.tsx @@ -0,0 +1,413 @@ +"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 { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + 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, + Copy, + Filter, + X, +} from "lucide-react"; +import { UNIDAD_MEDIDA_LABELS } from "@/types"; +import { UnidadMedida } from "@prisma/client"; + +interface APU { + id: string; + codigo: string; + descripcion: string; + unidad: UnidadMedida; + precioUnitario: number; + activo: boolean; + _count: { + partidas: number; + }; +} + +interface APUListProps { + apus: APU[]; +} + +export function APUList({ apus: initialApus }: APUListProps) { + const router = useRouter(); + const [apus, setApus] = useState(initialApus); + const [search, setSearch] = useState(""); + const [unidadFilter, setUnidadFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [precioMinFilter, setPrecioMinFilter] = useState(""); + const [precioMaxFilter, setPrecioMaxFilter] = useState(""); + const [showFilters, setShowFilters] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Get unique units from APUs for filter dropdown + const availableUnits = useMemo(() => { + const units = new Set(apus.map((apu) => apu.unidad)); + return Array.from(units).sort(); + }, [apus]); + + const filteredApus = useMemo(() => { + return apus.filter((apu) => { + // Text search + const matchesSearch = + apu.codigo.toLowerCase().includes(search.toLowerCase()) || + apu.descripcion.toLowerCase().includes(search.toLowerCase()); + + // Unit filter + const matchesUnidad = unidadFilter === "all" || apu.unidad === unidadFilter; + + // Status filter + const matchesStatus = + statusFilter === "all" || + (statusFilter === "active" && apu.activo) || + (statusFilter === "inactive" && !apu.activo); + + // Price range filter + const precioMin = precioMinFilter ? parseFloat(precioMinFilter) : 0; + const precioMax = precioMaxFilter ? parseFloat(precioMaxFilter) : Infinity; + const matchesPrecio = + apu.precioUnitario >= precioMin && apu.precioUnitario <= precioMax; + + return matchesSearch && matchesUnidad && matchesStatus && matchesPrecio; + }); + }, [apus, search, unidadFilter, statusFilter, precioMinFilter, precioMaxFilter]); + + const activeFiltersCount = useMemo(() => { + let count = 0; + if (unidadFilter !== "all") count++; + if (statusFilter !== "all") count++; + if (precioMinFilter) count++; + if (precioMaxFilter) count++; + return count; + }, [unidadFilter, statusFilter, precioMinFilter, precioMaxFilter]); + + const clearFilters = () => { + setUnidadFilter("all"); + setStatusFilter("all"); + setPrecioMinFilter(""); + setPrecioMaxFilter(""); + }; + + const handleDelete = async () => { + if (!deleteId) return; + + setIsDeleting(true); + try { + const response = await fetch(`/api/apu/${deleteId}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al eliminar"); + } + + setApus(apus.filter((a) => a.id !== deleteId)); + toast({ + title: "APU eliminado", + description: "El analisis de precio unitario ha sido eliminado", + }); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo eliminar el APU", + variant: "destructive", + }); + } finally { + setIsDeleting(false); + setDeleteId(null); + } + }; + + const handleDuplicate = async (apu: APU) => { + // Redirect to new APU form with prefilled data + router.push(`/apu/nuevo?duplicar=${apu.id}`); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+
+ + +
+
+ + {/* Filters Panel */} + {showFilters && ( +
+
+

Filtros

+ {activeFiltersCount > 0 && ( + + )} +
+
+
+ + +
+
+ + +
+
+ + setPrecioMinFilter(e.target.value)} + min="0" + step="0.01" + /> +
+
+ + setPrecioMaxFilter(e.target.value)} + min="0" + step="0.01" + /> +
+
+
+ Mostrando {filteredApus.length} de {apus.length} APUs +
+
+ )} + +
+ + + + Codigo + Descripcion + Unidad + Precio Unitario + Vinculado + Estado + + + + + {filteredApus.length === 0 ? ( + + + {search + ? "No se encontraron resultados" + : "No hay APUs registrados"} + + + ) : ( + filteredApus.map((apu) => ( + + {apu.codigo} + + + {apu.descripcion} + + + {UNIDAD_MEDIDA_LABELS[apu.unidad]} + + ${apu.precioUnitario.toFixed(2)} + + + {apu._count.partidas > 0 ? ( + + {apu._count.partidas} partida{apu._count.partidas !== 1 ? "s" : ""} + + ) : ( + - + )} + + + + {apu.activo ? "Activo" : "Inactivo"} + + + + + + + + + + + + Ver Detalle + + + + + + Editar + + + handleDuplicate(apu)}> + + Duplicar + + setDeleteId(apu.id)} + disabled={apu._count.partidas > 0} + > + + Eliminar + + + + + + )) + )} + +
+
+ + setDeleteId(null)}> + + + Eliminar APU + + Esta accion no se puede deshacer. El analisis de precio unitario + sera eliminado permanentemente. + + + + Cancelar + + {isDeleting ? "Eliminando..." : "Eliminar"} + + + + +
+ ); +} diff --git a/src/components/apu/configuracion-apu-form.tsx b/src/components/apu/configuracion-apu-form.tsx new file mode 100644 index 0000000..3108d3d --- /dev/null +++ b/src/components/apu/configuracion-apu-form.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + configuracionAPUSchema, + type ConfiguracionAPUInput, +} from "@/lib/validations/apu"; +import { toast } from "@/hooks/use-toast"; +import { Loader2, Settings } from "lucide-react"; + +interface ConfiguracionAPUFormProps { + configuracion: { + id: string; + porcentajeHerramientaMenor: number; + porcentajeIndirectos: number; + porcentajeUtilidad: number; + }; +} + +export function ConfiguracionAPUForm({ + configuracion, +}: ConfiguracionAPUFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(configuracionAPUSchema), + defaultValues: { + porcentajeHerramientaMenor: configuracion.porcentajeHerramientaMenor, + porcentajeIndirectos: configuracion.porcentajeIndirectos, + porcentajeUtilidad: configuracion.porcentajeUtilidad, + }, + }); + + const onSubmit = async (data: ConfiguracionAPUInput) => { + setIsLoading(true); + try { + const response = await fetch("/api/apu/configuracion", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al guardar"); + } + + toast({ + title: "Configuracion guardada", + description: + "Los porcentajes por defecto han sido actualizados correctamente", + }); + + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: + error instanceof Error + ? error.message + : "No se pudo guardar la configuracion", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + + + Porcentajes por Defecto + + + Estos valores se usaran como predeterminados al crear nuevos APUs + + + +
+ + + {errors.porcentajeHerramientaMenor && ( +

+ {errors.porcentajeHerramientaMenor.message} +

+ )} +

+ Porcentaje del costo de mano de obra que se agrega como costo de + herramienta menor. Tipicamente entre 2% y 5%. +

+
+ +
+ + + {errors.porcentajeIndirectos && ( +

+ {errors.porcentajeIndirectos.message} +

+ )} +

+ Incluye gastos de administracion, supervision, seguros, etc. + Tipicamente entre 5% y 15%. +

+
+ +
+ + + {errors.porcentajeUtilidad && ( +

+ {errors.porcentajeUtilidad.message} +

+ )} +

+ Margen de ganancia esperado. Tipicamente entre 8% y 15%. +

+
+
+
+ + + + Formula del Precio Unitario + + +
+

+ Costo Directo = Materiales + Mano de Obra + Equipo + + Herramienta Menor +

+

+ Herramienta Menor = Mano de Obra ×{" "} + {configuracion.porcentajeHerramientaMenor}% +

+

+ Costos Indirectos = Costo Directo ×{" "} + {configuracion.porcentajeIndirectos}% +

+

+ Utilidad = (Costo Directo + Indirectos) ×{" "} + {configuracion.porcentajeUtilidad}% +

+

+ Precio Unitario = Costo Directo + Indirectos + + Utilidad +

+
+
+
+ +
+ +
+
+ ); +} diff --git a/src/components/apu/equipo-form.tsx b/src/components/apu/equipo-form.tsx new file mode 100644 index 0000000..6cae39e --- /dev/null +++ b/src/components/apu/equipo-form.tsx @@ -0,0 +1,377 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + equipoMaquinariaSchema, + calcularCostoHorario, + type EquipoMaquinariaInput, +} from "@/lib/validations/apu"; +import { toast } from "@/hooks/use-toast"; +import { Loader2 } from "lucide-react"; +import { TIPO_EQUIPO_LABELS } from "@/types"; +import { TipoEquipo } from "@prisma/client"; + +interface EquipoFormProps { + equipo?: { + id: string; + codigo: string; + nombre: string; + tipo: TipoEquipo; + valorAdquisicion: number; + vidaUtilHoras: number; + valorRescate: number; + consumoCombustible: number | null; + precioCombustible: number | null; + factorMantenimiento: number; + costoOperador: number | null; + costoHorario: number; + }; +} + +export function EquipoForm({ equipo }: EquipoFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const isEditing = !!equipo; + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + watch, + } = useForm({ + resolver: zodResolver(equipoMaquinariaSchema), + defaultValues: { + codigo: equipo?.codigo || "", + nombre: equipo?.nombre || "", + tipo: equipo?.tipo || "MAQUINARIA_LIGERA", + valorAdquisicion: equipo?.valorAdquisicion || 0, + vidaUtilHoras: equipo?.vidaUtilHoras || 0, + valorRescate: equipo?.valorRescate || 0, + consumoCombustible: equipo?.consumoCombustible || undefined, + precioCombustible: equipo?.precioCombustible || undefined, + factorMantenimiento: equipo?.factorMantenimiento || 0.6, + costoOperador: equipo?.costoOperador || undefined, + }, + }); + + const watchedValues = watch(); + + // Calculate costo horario in real-time + const calculatedCostoHorario = useMemo(() => { + if (!watchedValues.valorAdquisicion || !watchedValues.vidaUtilHoras) { + return 0; + } + return calcularCostoHorario({ + valorAdquisicion: watchedValues.valorAdquisicion || 0, + vidaUtilHoras: watchedValues.vidaUtilHoras || 1, + valorRescate: watchedValues.valorRescate || 0, + consumoCombustible: watchedValues.consumoCombustible, + precioCombustible: watchedValues.precioCombustible, + factorMantenimiento: watchedValues.factorMantenimiento || 0.6, + costoOperador: watchedValues.costoOperador, + }); + }, [watchedValues]); + + // Calculate breakdown + const costoBreakdown = useMemo(() => { + const va = watchedValues.valorAdquisicion || 0; + const vh = watchedValues.vidaUtilHoras || 1; + const vr = watchedValues.valorRescate || 0; + const fm = watchedValues.factorMantenimiento || 0.6; + const cc = watchedValues.consumoCombustible || 0; + const pc = watchedValues.precioCombustible || 0; + const co = watchedValues.costoOperador || 0; + + const depreciacion = (va - vr) / vh; + const mantenimiento = depreciacion * fm; + const combustible = cc * pc; + + return { + depreciacion, + mantenimiento, + combustible, + operador: co, + }; + }, [watchedValues]); + + const onSubmit = async (data: EquipoMaquinariaInput) => { + setIsLoading(true); + try { + const url = isEditing + ? `/api/apu/equipos/${equipo.id}` + : "/api/apu/equipos"; + const method = isEditing ? "PUT" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al guardar"); + } + + toast({ + title: isEditing ? "Equipo actualizado" : "Equipo creado", + description: isEditing + ? "Los cambios han sido guardados" + : "El equipo ha sido creado exitosamente", + }); + + router.push("/apu/equipos"); + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo guardar el equipo", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Informacion General + + Datos basicos del equipo o maquinaria + + + +
+
+ + + {errors.codigo && ( +

{errors.codigo.message}

+ )} +
+ +
+ + + {errors.nombre && ( +

{errors.nombre.message}

+ )} +
+
+ +
+ + +
+
+
+ + + + Datos Economicos + + Valores para calcular el costo horario del equipo + + + +
+
+ + + {errors.valorAdquisicion && ( +

+ {errors.valorAdquisicion.message} +

+ )} +
+ +
+ + + {errors.vidaUtilHoras && ( +

+ {errors.vidaUtilHoras.message} +

+ )} +
+ +
+ + +
+
+ +
+
+ + +

+ Tipicamente entre 0.40 y 0.80 (60% por defecto) +

+
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + + + Resumen de Costo Horario + + +
+
+
+ Depreciacion: + + ${costoBreakdown.depreciacion.toFixed(2)}/hr + +
+
+ Mantenimiento: + + ${costoBreakdown.mantenimiento.toFixed(2)}/hr + +
+
+ Combustible: + + ${costoBreakdown.combustible.toFixed(2)}/hr + +
+
+ Operador: + + ${costoBreakdown.operador.toFixed(2)}/hr + +
+
+ Costo Horario Total: + + ${calculatedCostoHorario.toFixed(2)}/hr + +
+
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/src/components/apu/index.ts b/src/components/apu/index.ts new file mode 100644 index 0000000..bea8c95 --- /dev/null +++ b/src/components/apu/index.ts @@ -0,0 +1,7 @@ +export { APUForm } from "./apu-form"; +export { APUList } from "./apu-list"; +export { APUDetail } from "./apu-detail"; +export { ManoObraForm } from "./mano-obra-form"; +export { EquipoForm } from "./equipo-form"; +export { ConfiguracionAPUForm } from "./configuracion-apu-form"; +export { VincularAPUDialog } from "./vincular-apu-dialog"; diff --git a/src/components/apu/mano-obra-form.tsx b/src/components/apu/mano-obra-form.tsx new file mode 100644 index 0000000..53be65b --- /dev/null +++ b/src/components/apu/mano-obra-form.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + categoriaManoObraSchema, + calcularFSR, + type CategoriaManoObraInput, +} from "@/lib/validations/apu"; +import { toast } from "@/hooks/use-toast"; +import { Loader2 } from "lucide-react"; +import { CATEGORIA_MANO_OBRA_LABELS } from "@/types"; +import { CategoriaManoObra } from "@prisma/client"; + +interface ManoObraFormProps { + categoria?: { + id: string; + codigo: string; + nombre: string; + categoria: CategoriaManoObra; + salarioDiario: number; + factorIMSS: number; + factorINFONAVIT: number; + factorRetiro: number; + factorVacaciones: number; + factorPrimaVac: number; + factorAguinaldo: number; + factorSalarioReal: number; + salarioReal: number; + }; +} + +export function ManoObraForm({ categoria }: ManoObraFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const isEditing = !!categoria; + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + watch, + } = useForm({ + resolver: zodResolver(categoriaManoObraSchema), + defaultValues: { + codigo: categoria?.codigo || "", + nombre: categoria?.nombre || "", + categoria: categoria?.categoria || "PEON", + salarioDiario: categoria?.salarioDiario || 0, + factorIMSS: categoria?.factorIMSS || 0.2675, + factorINFONAVIT: categoria?.factorINFONAVIT || 0.05, + factorRetiro: categoria?.factorRetiro || 0.02, + factorVacaciones: categoria?.factorVacaciones || 0.0411, + factorPrimaVac: categoria?.factorPrimaVac || 0.0103, + factorAguinaldo: categoria?.factorAguinaldo || 0.0411, + }, + }); + + const watchedValues = watch(); + + // Calculate FSR and salario real in real-time + const calculatedValues = useMemo(() => { + const fsr = calcularFSR({ + factorIMSS: watchedValues.factorIMSS || 0, + factorINFONAVIT: watchedValues.factorINFONAVIT || 0, + factorRetiro: watchedValues.factorRetiro || 0, + factorVacaciones: watchedValues.factorVacaciones || 0, + factorPrimaVac: watchedValues.factorPrimaVac || 0, + factorAguinaldo: watchedValues.factorAguinaldo || 0, + }); + const salarioReal = (watchedValues.salarioDiario || 0) * fsr; + return { fsr, salarioReal }; + }, [watchedValues]); + + const onSubmit = async (data: CategoriaManoObraInput) => { + setIsLoading(true); + try { + const url = isEditing + ? `/api/apu/mano-obra/${categoria.id}` + : "/api/apu/mano-obra"; + const method = isEditing ? "PUT" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al guardar"); + } + + toast({ + title: isEditing ? "Categoria actualizada" : "Categoria creada", + description: isEditing + ? "Los cambios han sido guardados" + : "La categoria de mano de obra ha sido creada exitosamente", + }); + + router.push("/apu/mano-obra"); + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo guardar la categoria", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Informacion General + + Datos basicos de la categoria de mano de obra + + + +
+
+ + + {errors.codigo && ( +

{errors.codigo.message}

+ )} +
+ +
+ + + {errors.nombre && ( +

{errors.nombre.message}

+ )} +
+
+ +
+
+ + +
+ +
+ + + {errors.salarioDiario && ( +

+ {errors.salarioDiario.message} +

+ )} +
+
+
+
+ + + + Factor de Salario Real (FSR) + + Factores para calcular el salario real incluyendo prestaciones + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Resumen de Calculo

+
+
+ Factor de Salario Real: + + {calculatedValues.fsr.toFixed(4)} + +
+
+ Salario Real Diario: + + ${calculatedValues.salarioReal.toFixed(2)} + +
+
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/src/components/apu/vincular-apu-dialog.tsx b/src/components/apu/vincular-apu-dialog.tsx new file mode 100644 index 0000000..d913151 --- /dev/null +++ b/src/components/apu/vincular-apu-dialog.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Search, Link2, Loader2, Calculator } from "lucide-react"; +import { UNIDAD_MEDIDA_LABELS } from "@/types"; +import { UnidadMedida } from "@prisma/client"; + +interface APU { + id: string; + codigo: string; + descripcion: string; + unidad: UnidadMedida; + precioUnitario: number; +} + +interface VincularAPUDialogProps { + onSelect: (apu: APU) => void; + trigger?: React.ReactNode; +} + +export function VincularAPUDialog({ onSelect, trigger }: VincularAPUDialogProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [apus, setApus] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + fetchAPUs(); + } + }, [open]); + + const fetchAPUs = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch("/api/apu?activo=true"); + if (!response.ok) throw new Error("Error al cargar APUs"); + const data = await response.json(); + setApus(data); + } catch (err) { + setError("No se pudieron cargar los APUs"); + } finally { + setLoading(false); + } + }; + + const filteredAPUs = apus.filter( + (apu) => + apu.codigo.toLowerCase().includes(search.toLowerCase()) || + apu.descripcion.toLowerCase().includes(search.toLowerCase()) + ); + + const handleSelect = (apu: APU) => { + onSelect(apu); + setOpen(false); + setSearch(""); + }; + + return ( + + + {trigger || ( + + )} + + + + + + Seleccionar APU + + + Selecciona un Analisis de Precio Unitario para vincular a esta partida + + + +
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : filteredAPUs.length === 0 ? ( +
+ +

+ {search + ? "No se encontraron APUs con ese criterio" + : "No hay APUs disponibles"} +

+ {!search && ( + + )} +
+ ) : ( + + + + Codigo + Descripcion + Unidad + P.U. + + + + + {filteredAPUs.map((apu) => ( + handleSelect(apu)} + > + {apu.codigo} + {apu.descripcion} + + + {UNIDAD_MEDIDA_LABELS[apu.unidad]} + + + + ${apu.precioUnitario.toFixed(2)} + + + + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 835173e..2afafda 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -14,6 +14,7 @@ import { Settings, ChevronLeft, ChevronRight, + Calculator, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useState } from "react"; @@ -29,6 +30,11 @@ const navigation = [ href: "/obras", icon: HardHat, }, + { + name: "APU", + href: "/apu", + icon: Calculator, + }, { name: "Finanzas", href: "/finanzas", diff --git a/src/components/pdf/apu-pdf.tsx b/src/components/pdf/apu-pdf.tsx new file mode 100644 index 0000000..b1919ca --- /dev/null +++ b/src/components/pdf/apu-pdf.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { + Document, + Page, + Text, + View, +} from "@react-pdf/renderer"; +import { styles, formatCurrencyPDF, formatDatePDF } from "./styles"; +import { UNIDAD_MEDIDA_LABELS, TIPO_INSUMO_APU_LABELS } from "@/types"; +import { UnidadMedida, TipoInsumoAPU } from "@prisma/client"; + +interface Insumo { + tipo: TipoInsumoAPU; + descripcion: string; + unidad: UnidadMedida; + cantidad: number; + desperdicio: number; + cantidadConDesperdicio: number; + rendimiento: number | null; + precioUnitario: number; + importe: number; + material?: { codigo: string; nombre: string } | null; + categoriaManoObra?: { codigo: string; nombre: string } | null; + equipo?: { codigo: string; nombre: string } | null; +} + +interface APUPDFProps { + apu: { + codigo: string; + descripcion: string; + unidad: UnidadMedida; + rendimientoDiario: number | null; + costoMateriales: number; + costoManoObra: number; + costoEquipo: number; + costoHerramienta: number; + costoDirecto: number; + porcentajeIndirectos: number; + costoIndirectos: number; + porcentajeUtilidad: number; + costoUtilidad: number; + precioUnitario: number; + insumos: Insumo[]; + }; + empresaNombre?: string; +} + +export function APUPDF({ apu, empresaNombre = "Mexus App" }: APUPDFProps) { + const materialesInsumos = apu.insumos.filter((i) => i.tipo === "MATERIAL"); + const manoObraInsumos = apu.insumos.filter((i) => i.tipo === "MANO_OBRA"); + const equipoInsumos = apu.insumos.filter((i) => i.tipo === "EQUIPO"); + + return ( + + + {/* Header */} + + Analisis de Precio Unitario + {apu.codigo} - {apu.descripcion} + + Generado el {formatDatePDF(new Date())} | {empresaNombre} + + + + {/* Info General */} + + + + + Codigo: + {apu.codigo} + + + Descripcion: + {apu.descripcion} + + + + + Unidad: + {UNIDAD_MEDIDA_LABELS[apu.unidad]} + + + Rendimiento: + + {apu.rendimientoDiario + ? `${apu.rendimientoDiario} ${UNIDAD_MEDIDA_LABELS[apu.unidad]}/jornada` + : "N/A"} + + + + + + + {/* Materiales */} + {materialesInsumos.length > 0 && ( + + Materiales + + + Codigo + Descripcion + Cant. + Desp.% + C/Desp. + Unidad + P.U. + Importe + + {materialesInsumos.map((insumo, index) => ( + + + {insumo.material?.codigo || "-"} + + {insumo.descripcion} + + {insumo.cantidad.toFixed(4)} + + + {insumo.desperdicio}% + + + {insumo.cantidadConDesperdicio.toFixed(4)} + + + {UNIDAD_MEDIDA_LABELS[insumo.unidad]} + + + {formatCurrencyPDF(insumo.precioUnitario)} + + + {formatCurrencyPDF(insumo.importe)} + + + ))} + + + Subtotal Materiales + + + {formatCurrencyPDF(apu.costoMateriales)} + + + + + )} + + {/* Mano de Obra */} + {manoObraInsumos.length > 0 && ( + + Mano de Obra + + + Codigo + Descripcion + Cantidad + Rend. + Salario + Importe + + {manoObraInsumos.map((insumo, index) => ( + + + {insumo.categoriaManoObra?.codigo || "-"} + + {insumo.descripcion} + + {insumo.cantidad} + + + {insumo.rendimiento || "-"} + + + {formatCurrencyPDF(insumo.precioUnitario)} + + + {formatCurrencyPDF(insumo.importe)} + + + ))} + + + Subtotal Mano de Obra + + + {formatCurrencyPDF(apu.costoManoObra)} + + + + + )} + + {/* Equipo */} + {equipoInsumos.length > 0 && ( + + Equipo y Maquinaria + + + Codigo + Descripcion + Horas + C. Horario + Importe + + {equipoInsumos.map((insumo, index) => ( + + + {insumo.equipo?.codigo || "-"} + + {insumo.descripcion} + + {insumo.cantidad} + + + {formatCurrencyPDF(insumo.precioUnitario)} + + + {formatCurrencyPDF(insumo.importe)} + + + ))} + + + Subtotal Equipo + + + {formatCurrencyPDF(apu.costoEquipo)} + + + + + )} + + {/* Resumen de Costos */} + + Resumen de Costos + + + Materiales: + + {formatCurrencyPDF(apu.costoMateriales)} + + + + Mano de Obra: + + {formatCurrencyPDF(apu.costoManoObra)} + + + + Equipo: + + {formatCurrencyPDF(apu.costoEquipo)} + + + + Herramienta Menor: + + {formatCurrencyPDF(apu.costoHerramienta)} + + + + Costo Directo: + + {formatCurrencyPDF(apu.costoDirecto)} + + + + Indirectos ({apu.porcentajeIndirectos}%): + + {formatCurrencyPDF(apu.costoIndirectos)} + + + + Utilidad ({apu.porcentajeUtilidad}%): + + {formatCurrencyPDF(apu.costoUtilidad)} + + + + + PRECIO UNITARIO: + + + {formatCurrencyPDF(apu.precioUnitario)} + + + + + + {/* Footer */} + + {empresaNombre} - Sistema de Gestion de Obras + `Pagina ${pageNumber} de ${totalPages}`} /> + + + + ); +} diff --git a/src/components/pdf/index.ts b/src/components/pdf/index.ts index a6f18eb..e55ce17 100644 --- a/src/components/pdf/index.ts +++ b/src/components/pdf/index.ts @@ -2,5 +2,6 @@ export { ReporteObraPDF } from "./reporte-obra-pdf"; export { PresupuestoPDF } from "./presupuesto-pdf"; export { GastosPDF } from "./gastos-pdf"; export { BitacoraPDF } from "./bitacora-pdf"; +export { APUPDF } from "./apu-pdf"; export { ExportPDFButton, ExportPDFMenu } from "./export-pdf-button"; export * from "./styles"; diff --git a/src/components/presupuesto/explosion-insumos.tsx b/src/components/presupuesto/explosion-insumos.tsx new file mode 100644 index 0000000..6b4952e --- /dev/null +++ b/src/components/presupuesto/explosion-insumos.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "@/hooks/use-toast"; +import { + Package, + Users, + Wrench, + Loader2, + FileSpreadsheet, + AlertTriangle, + Download, +} from "lucide-react"; +import { UNIDAD_MEDIDA_LABELS } from "@/types"; +import { UnidadMedida, TipoInsumoAPU } from "@prisma/client"; +import * as XLSX from "xlsx"; + +interface InsumoConsolidado { + tipo: TipoInsumoAPU; + descripcion: string; + unidad: UnidadMedida; + cantidadTotal: number; + precioUnitario: number; + importeTotal: number; + codigo?: string; + partidasCount: number; +} + +interface ExplosionData { + presupuesto: { + id: string; + nombre: string; + total: number; + obra: { + id: string; + nombre: string; + }; + }; + partidasConAPU: number; + explosion: { + materiales: InsumoConsolidado[]; + manoObra: InsumoConsolidado[]; + equipos: InsumoConsolidado[]; + }; + totales: { + materiales: number; + manoObra: number; + equipos: number; + total: number; + }; +} + +interface ExplosionInsumosProps { + presupuestoId: string; + presupuestoNombre: string; + trigger?: React.ReactNode; +} + +export function ExplosionInsumos({ + presupuestoId, + presupuestoNombre, + trigger, +}: ExplosionInsumosProps) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (open && !data) { + fetchExplosion(); + } + }, [open]); + + const fetchExplosion = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/presupuestos/${presupuestoId}/explosion`); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || "Error al obtener explosion"); + } + const explosionData = await response.json(); + setData(explosionData); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + toast({ + title: "Error", + description: "No se pudo cargar la explosion de insumos", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const formatCurrency = (value: number) => + value.toLocaleString("es-MX", { + style: "currency", + currency: "MXN", + }); + + const formatQuantity = (value: number) => + value.toLocaleString("es-MX", { minimumFractionDigits: 2, maximumFractionDigits: 4 }); + + const exportToExcel = () => { + if (!data) return; + + // Create workbook + const wb = XLSX.utils.book_new(); + + // Helper to create a sheet from insumos + const createSheet = (insumos: InsumoConsolidado[], title: string) => { + const rows = insumos.map((i) => ({ + Codigo: i.codigo || "-", + Descripcion: i.descripcion, + Unidad: UNIDAD_MEDIDA_LABELS[i.unidad], + Cantidad: i.cantidadTotal, + "Precio Unitario": i.precioUnitario, + Importe: i.importeTotal, + "No. Partidas": i.partidasCount, + })); + + // Add total row + const total = insumos.reduce((sum, i) => sum + i.importeTotal, 0); + rows.push({ + Codigo: "", + Descripcion: "TOTAL", + Unidad: "", + Cantidad: 0, + "Precio Unitario": 0, + Importe: total, + "No. Partidas": 0, + }); + + const ws = XLSX.utils.json_to_sheet(rows); + + // Set column widths + ws["!cols"] = [ + { wch: 12 }, // Codigo + { wch: 40 }, // Descripcion + { wch: 10 }, // Unidad + { wch: 12 }, // Cantidad + { wch: 14 }, // P.U. + { wch: 14 }, // Importe + { wch: 12 }, // Partidas + ]; + + return ws; + }; + + // Add sheets + if (data.explosion.materiales.length > 0) { + XLSX.utils.book_append_sheet( + wb, + createSheet(data.explosion.materiales, "Materiales"), + "Materiales" + ); + } + + if (data.explosion.manoObra.length > 0) { + XLSX.utils.book_append_sheet( + wb, + createSheet(data.explosion.manoObra, "Mano de Obra"), + "Mano de Obra" + ); + } + + if (data.explosion.equipos.length > 0) { + XLSX.utils.book_append_sheet( + wb, + createSheet(data.explosion.equipos, "Equipos"), + "Equipos" + ); + } + + // Add summary sheet + const summaryData = [ + { Concepto: "Materiales", Importe: data.totales.materiales }, + { Concepto: "Mano de Obra", Importe: data.totales.manoObra }, + { Concepto: "Equipos", Importe: data.totales.equipos }, + { Concepto: "TOTAL", Importe: data.totales.total }, + ]; + const summaryWs = XLSX.utils.json_to_sheet(summaryData); + summaryWs["!cols"] = [{ wch: 20 }, { wch: 16 }]; + XLSX.utils.book_append_sheet(wb, summaryWs, "Resumen"); + + // Generate filename + const filename = `explosion-insumos-${presupuestoNombre.toLowerCase().replace(/\s+/g, "-")}.xlsx`; + + // Download + XLSX.writeFile(wb, filename); + + toast({ + title: "Exportacion exitosa", + description: "El archivo Excel ha sido descargado", + }); + }; + + return ( + + + {trigger || ( + + )} + + + +
+ + + Explosion de Insumos + + {data && data.partidasConAPU > 0 && ( + + )} +
+ + Lista consolidada de todos los materiales, mano de obra y equipos + necesarios para {presupuestoNombre} + +
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : data ? ( +
+ {/* Summary Cards */} +
+ + + + Materiales + + + +

+ {formatCurrency(data.totales.materiales)} +

+
+
+ + + + Mano de Obra + + + +

+ {formatCurrency(data.totales.manoObra)} +

+
+
+ + + + Equipos + + + +

+ {formatCurrency(data.totales.equipos)} +

+
+
+ + + + Total + + + +

+ {formatCurrency(data.totales.total)} +

+
+
+
+ + {data.partidasConAPU === 0 ? ( +
+ +

+ No hay partidas con APU vinculado en este presupuesto. +
+ Vincula APUs a las partidas para ver la explosion de insumos. +

+
+ ) : ( + + + + + Materiales ({data.explosion.materiales.length}) + + + + Mano de Obra ({data.explosion.manoObra.length}) + + + + Equipos ({data.explosion.equipos.length}) + + + + + + + + + + + + + + + + )} +
+ ) : null} +
+
+ ); +} + +interface InsumoTableProps { + insumos: InsumoConsolidado[]; + formatCurrency: (value: number) => string; + formatQuantity: (value: number) => string; + emptyMessage: string; +} + +function InsumoTable({ + insumos, + formatCurrency, + formatQuantity, + emptyMessage, +}: InsumoTableProps) { + if (insumos.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + const total = insumos.reduce((sum, i) => sum + i.importeTotal, 0); + + return ( +
+ + + + Codigo + Descripcion + Unidad + Cantidad + P.U. + Importe + Partidas + + + + {insumos.map((insumo, index) => ( + + + {insumo.codigo || "-"} + + {insumo.descripcion} + + + {UNIDAD_MEDIDA_LABELS[insumo.unidad]} + + + + {formatQuantity(insumo.cantidadTotal)} + + + {formatCurrency(insumo.precioUnitario)} + + + {formatCurrency(insumo.importeTotal)} + + + {insumo.partidasCount} + + + ))} + + Total + + {formatCurrency(total)} + + + + +
+
+ ); +} diff --git a/src/components/presupuesto/index.ts b/src/components/presupuesto/index.ts new file mode 100644 index 0000000..7679195 --- /dev/null +++ b/src/components/presupuesto/index.ts @@ -0,0 +1,2 @@ +export { PartidasManager } from "./partidas-manager"; +export { ExplosionInsumos } from "./explosion-insumos"; diff --git a/src/components/presupuesto/partidas-manager.tsx b/src/components/presupuesto/partidas-manager.tsx new file mode 100644 index 0000000..1f2cd79 --- /dev/null +++ b/src/components/presupuesto/partidas-manager.tsx @@ -0,0 +1,586 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { toast } from "@/hooks/use-toast"; +import { + Plus, + MoreHorizontal, + Pencil, + Trash2, + Link2, + Unlink, + Calculator, + Loader2, +} from "lucide-react"; +import { VincularAPUDialog } from "@/components/apu/vincular-apu-dialog"; +import { + UNIDAD_MEDIDA_LABELS, + CATEGORIA_GASTO_LABELS, +} from "@/types"; +import { UnidadMedida, CategoriaGasto } from "@prisma/client"; + +const partidaSchema = z.object({ + codigo: z.string().min(1, "El codigo es requerido"), + descripcion: z.string().min(3, "La descripcion es requerida"), + unidad: z.string(), + cantidad: z.number().positive("La cantidad debe ser mayor a 0"), + precioUnitario: z.number().min(0, "El precio no puede ser negativo"), + categoria: z.string(), + apuId: z.string().optional(), +}); + +type PartidaFormData = z.infer; + +interface APU { + id: string; + codigo: string; + descripcion: string; + unidad: UnidadMedida; + precioUnitario: number; +} + +interface Partida { + id: string; + codigo: string; + descripcion: string; + unidad: UnidadMedida; + cantidad: number; + precioUnitario: number; + total: number; + categoria: CategoriaGasto; + apu: { + id: string; + codigo: string; + descripcion: string; + precioUnitario: number; + } | null; +} + +interface PartidasManagerProps { + presupuestoId: string; + presupuestoNombre: string; + partidas: Partida[]; + total: number; + aprobado: boolean; +} + +export function PartidasManager({ + presupuestoId, + presupuestoNombre, + partidas: initialPartidas, + total: initialTotal, + aprobado, +}: PartidasManagerProps) { + const router = useRouter(); + const [partidas, setPartidas] = useState(initialPartidas); + const [total, setTotal] = useState(initialTotal); + const [showForm, setShowForm] = useState(false); + const [editingPartida, setEditingPartida] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [selectedAPU, setSelectedAPU] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + watch, + reset, + } = useForm({ + resolver: zodResolver(partidaSchema), + defaultValues: { + codigo: "", + descripcion: "", + unidad: "METRO_CUADRADO", + cantidad: 1, + precioUnitario: 0, + categoria: "MATERIALES", + }, + }); + + const watchedValues = watch(); + const calculatedTotal = (watchedValues.cantidad || 0) * (watchedValues.precioUnitario || 0); + + const openCreateForm = () => { + reset({ + codigo: "", + descripcion: "", + unidad: "METRO_CUADRADO", + cantidad: 1, + precioUnitario: 0, + categoria: "MATERIALES", + }); + setSelectedAPU(null); + setEditingPartida(null); + setShowForm(true); + }; + + const openEditForm = (partida: Partida) => { + reset({ + codigo: partida.codigo, + descripcion: partida.descripcion, + unidad: partida.unidad, + cantidad: partida.cantidad, + precioUnitario: partida.precioUnitario, + categoria: partida.categoria, + apuId: partida.apu?.id, + }); + setSelectedAPU(partida.apu ? { + id: partida.apu.id, + codigo: partida.apu.codigo, + descripcion: partida.apu.descripcion, + unidad: partida.unidad, + precioUnitario: partida.apu.precioUnitario, + } : null); + setEditingPartida(partida); + setShowForm(true); + }; + + const handleAPUSelect = (apu: APU) => { + setSelectedAPU(apu); + setValue("descripcion", apu.descripcion); + setValue("unidad", apu.unidad); + setValue("precioUnitario", apu.precioUnitario); + setValue("apuId", apu.id); + }; + + const handleUnlinkAPU = () => { + setSelectedAPU(null); + setValue("apuId", undefined); + }; + + const onSubmit = async (data: PartidaFormData) => { + setIsLoading(true); + try { + const url = editingPartida + ? `/api/presupuestos/${presupuestoId}/partidas/${editingPartida.id}` + : `/api/presupuestos/${presupuestoId}/partidas`; + const method = editingPartida ? "PUT" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...data, + apuId: selectedAPU?.id || null, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al guardar"); + } + + toast({ + title: editingPartida ? "Partida actualizada" : "Partida creada", + description: editingPartida + ? "Los cambios han sido guardados" + : "La partida ha sido agregada al presupuesto", + }); + + setShowForm(false); + router.refresh(); + + // Refresh partidas list + const partidasResponse = await fetch(`/api/presupuestos/${presupuestoId}/partidas`); + if (partidasResponse.ok) { + const newPartidas = await partidasResponse.json(); + setPartidas(newPartidas); + const newTotal = newPartidas.reduce((sum: number, p: Partida) => sum + p.total, 0); + setTotal(newTotal); + } + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo guardar la partida", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async () => { + if (!deleteId) return; + + setIsLoading(true); + try { + const response = await fetch( + `/api/presupuestos/${presupuestoId}/partidas/${deleteId}`, + { method: "DELETE" } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al eliminar"); + } + + setPartidas(partidas.filter((p) => p.id !== deleteId)); + setTotal(partidas.filter((p) => p.id !== deleteId).reduce((sum, p) => sum + p.total, 0)); + + toast({ + title: "Partida eliminada", + description: "La partida ha sido eliminada del presupuesto", + }); + + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "No se pudo eliminar la partida", + variant: "destructive", + }); + } finally { + setIsLoading(false); + setDeleteId(null); + } + }; + + return ( +
+
+
+

{presupuestoNombre}

+

+ {partidas.length} partidas - Total: ${total.toLocaleString("es-MX", { minimumFractionDigits: 2 })} +

+
+ +
+ + {partidas.length === 0 ? ( + + + +

No hay partidas en este presupuesto

+ +
+
+ ) : ( +
+ + + + Codigo + Descripcion + Unidad + Cant. + P.U. + Total + APU + + + + + {partidas.map((partida) => ( + + {partida.codigo} + {partida.descripcion} + {UNIDAD_MEDIDA_LABELS[partida.unidad]} + + {partida.cantidad.toFixed(2)} + + + ${partida.precioUnitario.toFixed(2)} + + + ${partida.total.toFixed(2)} + + + {partida.apu ? ( + window.open(`/apu/${partida.apu!.id}`, "_blank")} + > + + {partida.apu.codigo} + + ) : ( + - + )} + + + + + + + + openEditForm(partida)}> + + Editar + + setDeleteId(partida.id)} + > + + Eliminar + + + + + + ))} + + Total Presupuesto + + ${total.toLocaleString("es-MX", { minimumFractionDigits: 2 })} + + + + +
+
+ )} + + {/* Form Dialog */} + + + + + {editingPartida ? "Editar Partida" : "Nueva Partida"} + + + {editingPartida + ? "Modifica los datos de la partida" + : "Agrega una nueva partida al presupuesto"} + + + +
+ {/* APU Link */} +
+
+
+ + + {selectedAPU ? "APU Vinculado" : "Sin APU vinculado"} + +
+
+ {selectedAPU ? ( + <> + {selectedAPU.codigo} + + + ) : ( + + )} +
+
+ {selectedAPU && ( +

+ {selectedAPU.descripcion} - ${selectedAPU.precioUnitario.toFixed(2)} +

+ )} +
+ +
+
+ + + {errors.codigo && ( +

{errors.codigo.message}

+ )} +
+ +
+ + +
+
+ +
+ + + {errors.descripcion && ( +

{errors.descripcion.message}

+ )} +
+ +
+
+ + +
+ +
+ + + {errors.cantidad && ( +

{errors.cantidad.message}

+ )} +
+ +
+ + + {errors.precioUnitario && ( +

{errors.precioUnitario.message}

+ )} +
+
+ +
+
+ Total: + + ${calculatedTotal.toLocaleString("es-MX", { minimumFractionDigits: 2 })} + +
+
+ +
+ + +
+
+
+
+ + {/* Delete Confirmation */} + setDeleteId(null)}> + + + Eliminar Partida + + Esta accion no se puede deshacer. La partida sera eliminada + permanentemente del presupuesto. + + + + Cancelar + + {isLoading ? "Eliminando..." : "Eliminar"} + + + + +
+ ); +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..30fc44d --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 83f7663..b2a778f 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -86,6 +86,11 @@ export const partidaPresupuestoSchema = z.object({ "PIEZA", "ROLLO", "CAJA", + "HORA", + "JORNADA", + "VIAJE", + "LOTE", + "GLOBAL", ]), cantidad: z.number().positive("La cantidad debe ser mayor a 0"), precioUnitario: z.number().positive("El precio debe ser mayor a 0"), @@ -119,6 +124,11 @@ export const materialSchema = z.object({ "PIEZA", "ROLLO", "CAJA", + "HORA", + "JORNADA", + "VIAJE", + "LOTE", + "GLOBAL", ]), precioUnitario: z.number().positive("El precio debe ser mayor a 0"), stockMinimo: z.number().min(0, "El stock minimo no puede ser negativo"), diff --git a/src/lib/validations/apu.ts b/src/lib/validations/apu.ts new file mode 100644 index 0000000..446b49a --- /dev/null +++ b/src/lib/validations/apu.ts @@ -0,0 +1,259 @@ +import { z } from "zod"; + +// Enum schemas +export const categoriaManoObraEnum = z.enum([ + "PEON", + "AYUDANTE", + "OFICIAL_ALBANIL", + "OFICIAL_FIERRERO", + "OFICIAL_CARPINTERO", + "OFICIAL_PLOMERO", + "OFICIAL_ELECTRICISTA", + "CABO", + "MAESTRO_OBRA", + "OPERADOR_EQUIPO", +]); + +export const tipoEquipoEnum = z.enum([ + "MAQUINARIA_PESADA", + "MAQUINARIA_LIGERA", + "HERRAMIENTA_ELECTRICA", + "TRANSPORTE", +]); + +export const tipoInsumoAPUEnum = z.enum([ + "MATERIAL", + "MANO_OBRA", + "EQUIPO", + "HERRAMIENTA_MENOR", +]); + +export const unidadMedidaEnum = z.enum([ + "UNIDAD", + "METRO", + "METRO_CUADRADO", + "METRO_CUBICO", + "KILOGRAMO", + "TONELADA", + "LITRO", + "BOLSA", + "PIEZA", + "ROLLO", + "CAJA", + "HORA", + "JORNADA", + "VIAJE", + "LOTE", + "GLOBAL", +]); + +// Categoría de Mano de Obra Schema +export const categoriaManoObraSchema = z.object({ + codigo: z.string().min(1, "El codigo es requerido"), + nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"), + categoria: categoriaManoObraEnum, + salarioDiario: z.number().positive("El salario diario debe ser mayor a 0"), + factorIMSS: z.number().min(0).max(1).default(0.2675), + factorINFONAVIT: z.number().min(0).max(1).default(0.05), + factorRetiro: z.number().min(0).max(1).default(0.02), + factorVacaciones: z.number().min(0).max(1).default(0.0411), + factorPrimaVac: z.number().min(0).max(1).default(0.0103), + factorAguinaldo: z.number().min(0).max(1).default(0.0411), +}); + +export const categoriaManoObraUpdateSchema = categoriaManoObraSchema.partial().extend({ + id: z.string(), +}); + +// Equipo/Maquinaria Schema +export const equipoMaquinariaSchema = z.object({ + codigo: z.string().min(1, "El codigo es requerido"), + nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"), + tipo: tipoEquipoEnum, + valorAdquisicion: z.number().positive("El valor de adquisicion debe ser mayor a 0"), + vidaUtilHoras: z.number().positive("La vida util debe ser mayor a 0"), + valorRescate: z.number().min(0).default(0), + consumoCombustible: z.number().min(0).optional(), + precioCombustible: z.number().min(0).optional(), + factorMantenimiento: z.number().min(0).max(2).default(0.60), + costoOperador: z.number().min(0).optional(), +}); + +export const equipoMaquinariaUpdateSchema = equipoMaquinariaSchema.partial().extend({ + id: z.string(), +}); + +// Insumo APU Schema +export const insumoAPUSchema = z.object({ + tipo: tipoInsumoAPUEnum, + descripcion: z.string().min(1, "La descripcion es requerida"), + unidad: unidadMedidaEnum, + cantidad: z.number().positive("La cantidad debe ser mayor a 0"), + desperdicio: z.number().min(0).max(100).default(0), + rendimiento: z.number().positive().optional(), + precioUnitario: z.number().min(0, "El precio no puede ser negativo"), + materialId: z.string().optional(), + categoriaManoObraId: z.string().optional(), + equipoId: z.string().optional(), +}); + +// APU Schema +export const apuSchema = z.object({ + codigo: z.string().min(1, "El codigo es requerido"), + descripcion: z.string().min(3, "La descripcion debe tener al menos 3 caracteres"), + unidad: unidadMedidaEnum, + rendimientoDiario: z.number().positive().optional(), + porcentajeIndirectos: z.number().min(0).max(100).optional(), + porcentajeUtilidad: z.number().min(0).max(100).optional(), + insumos: z.array(insumoAPUSchema).min(1, "Se requiere al menos un insumo"), +}); + +export const apuUpdateSchema = z.object({ + id: z.string(), + codigo: z.string().min(1).optional(), + descripcion: z.string().min(3).optional(), + unidad: unidadMedidaEnum.optional(), + rendimientoDiario: z.number().positive().optional().nullable(), + porcentajeIndirectos: z.number().min(0).max(100).optional(), + porcentajeUtilidad: z.number().min(0).max(100).optional(), + insumos: z.array(insumoAPUSchema).optional(), +}); + +// Configuración APU Schema +export const configuracionAPUSchema = z.object({ + porcentajeHerramientaMenor: z.number().min(0).max(100).default(3), + porcentajeIndirectos: z.number().min(0).max(100).default(8), + porcentajeUtilidad: z.number().min(0).max(100).default(10), +}); + +// Types inferidos +export type CategoriaManoObraInput = z.infer; +export type CategoriaManoObraUpdateInput = z.infer; +export type EquipoMaquinariaInput = z.infer; +export type EquipoMaquinariaUpdateInput = z.infer; +export type InsumoAPUInput = z.infer; +export type APUInput = z.infer; +export type APUUpdateInput = z.infer; +export type ConfiguracionAPUInput = z.infer; + +// Función helper para calcular FSR +export function calcularFSR(factores: { + factorIMSS: number; + factorINFONAVIT: number; + factorRetiro: number; + factorVacaciones: number; + factorPrimaVac: number; + factorAguinaldo: number; +}): number { + return 1 + + factores.factorIMSS + + factores.factorINFONAVIT + + factores.factorRetiro + + factores.factorVacaciones + + factores.factorPrimaVac + + factores.factorAguinaldo; +} + +// Función helper para calcular costo horario de equipo +export function calcularCostoHorario(equipo: { + valorAdquisicion: number; + vidaUtilHoras: number; + valorRescate: number; + consumoCombustible?: number | null; + precioCombustible?: number | null; + factorMantenimiento: number; + costoOperador?: number | null; +}): number { + // Depreciación por hora + const depreciacion = (equipo.valorAdquisicion - equipo.valorRescate) / equipo.vidaUtilHoras; + + // Costo de mantenimiento + const mantenimiento = depreciacion * equipo.factorMantenimiento; + + // Costo de combustible + const combustible = (equipo.consumoCombustible || 0) * (equipo.precioCombustible || 0); + + // Costo de operador + const operador = equipo.costoOperador || 0; + + return depreciacion + mantenimiento + combustible + operador; +} + +// Función helper para calcular importe de insumo +export function calcularImporteInsumo(insumo: { + tipo: string; + cantidad: number; + desperdicio: number; + rendimiento?: number | null; + precioUnitario: number; +}): { cantidadConDesperdicio: number; importe: number } { + const cantidadConDesperdicio = insumo.cantidad * (1 + insumo.desperdicio / 100); + + let importe: number; + if (insumo.tipo === "MANO_OBRA" && insumo.rendimiento && insumo.rendimiento > 0) { + // Para mano de obra: importe = (1/rendimiento) * salarioReal + importe = (1 / insumo.rendimiento) * insumo.precioUnitario; + } else { + // Para materiales y equipo: importe = cantidad con desperdicio * precio unitario + importe = cantidadConDesperdicio * insumo.precioUnitario; + } + + return { cantidadConDesperdicio, importe }; +} + +// Función helper para calcular totales del APU +export function calcularTotalesAPU( + insumos: Array<{ tipo: string; importe: number }>, + config: { + porcentajeHerramientaMenor: number; + porcentajeIndirectos: number; + porcentajeUtilidad: number; + } +): { + costoMateriales: number; + costoManoObra: number; + costoEquipo: number; + costoHerramienta: number; + costoDirecto: number; + costoIndirectos: number; + costoUtilidad: number; + precioUnitario: number; +} { + const costoMateriales = insumos + .filter(i => i.tipo === "MATERIAL") + .reduce((sum, i) => sum + i.importe, 0); + + const costoManoObra = insumos + .filter(i => i.tipo === "MANO_OBRA") + .reduce((sum, i) => sum + i.importe, 0); + + const costoEquipo = insumos + .filter(i => i.tipo === "EQUIPO") + .reduce((sum, i) => sum + i.importe, 0); + + // Herramienta menor como porcentaje de mano de obra + const costoHerramienta = costoManoObra * (config.porcentajeHerramientaMenor / 100); + + // Costo directo total + const costoDirecto = costoMateriales + costoManoObra + costoEquipo + costoHerramienta; + + // Indirectos sobre costo directo + const costoIndirectos = costoDirecto * (config.porcentajeIndirectos / 100); + + // Utilidad sobre (costo directo + indirectos) + const costoUtilidad = (costoDirecto + costoIndirectos) * (config.porcentajeUtilidad / 100); + + // Precio unitario final + const precioUnitario = costoDirecto + costoIndirectos + costoUtilidad; + + return { + costoMateriales, + costoManoObra, + costoEquipo, + costoHerramienta, + costoDirecto, + costoIndirectos, + costoUtilidad, + precioUnitario, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index af4d49c..a4c80d9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,9 @@ import { PrioridadOrden, TipoNotificacion, TipoActividad, + CategoriaManoObra, + TipoEquipo, + TipoInsumoAPU, } from "@prisma/client"; export type { @@ -32,6 +35,9 @@ export type { PrioridadOrden, TipoNotificacion, TipoActividad, + CategoriaManoObra, + TipoEquipo, + TipoInsumoAPU, }; export interface DashboardStats { @@ -148,6 +154,11 @@ export const UNIDAD_MEDIDA_LABELS: Record = { PIEZA: "Pieza", ROLLO: "Rollo", CAJA: "Caja", + HORA: "Hora", + JORNADA: "Jornada", + VIAJE: "Viaje", + LOTE: "Lote", + GLOBAL: "Global", }; export const ESTADO_FACTURA_LABELS: Record = { @@ -237,3 +248,46 @@ export const PRIORIDAD_ORDEN_COLORS: Record = { ALTA: "bg-orange-100 text-orange-800", URGENTE: "bg-red-100 text-red-800", }; + +// ============== APU LABELS ============== + +export const CATEGORIA_MANO_OBRA_LABELS: Record = { + PEON: "Peon", + AYUDANTE: "Ayudante", + OFICIAL_ALBANIL: "Oficial Albanil", + OFICIAL_FIERRERO: "Oficial Fierrero", + OFICIAL_CARPINTERO: "Oficial Carpintero", + OFICIAL_PLOMERO: "Oficial Plomero", + OFICIAL_ELECTRICISTA: "Oficial Electricista", + CABO: "Cabo", + MAESTRO_OBRA: "Maestro de Obra", + OPERADOR_EQUIPO: "Operador de Equipo", +}; + +export const TIPO_EQUIPO_LABELS: Record = { + MAQUINARIA_PESADA: "Maquinaria Pesada", + MAQUINARIA_LIGERA: "Maquinaria Ligera", + HERRAMIENTA_ELECTRICA: "Herramienta Electrica", + TRANSPORTE: "Transporte", +}; + +export const TIPO_EQUIPO_COLORS: Record = { + MAQUINARIA_PESADA: "bg-orange-100 text-orange-800", + MAQUINARIA_LIGERA: "bg-blue-100 text-blue-800", + HERRAMIENTA_ELECTRICA: "bg-yellow-100 text-yellow-800", + TRANSPORTE: "bg-green-100 text-green-800", +}; + +export const TIPO_INSUMO_APU_LABELS: Record = { + MATERIAL: "Material", + MANO_OBRA: "Mano de Obra", + EQUIPO: "Equipo", + HERRAMIENTA_MENOR: "Herramienta Menor", +}; + +export const TIPO_INSUMO_APU_COLORS: Record = { + MATERIAL: "bg-emerald-100 text-emerald-800", + MANO_OBRA: "bg-blue-100 text-blue-800", + EQUIPO: "bg-orange-100 text-orange-800", + HERRAMIENTA_MENOR: "bg-gray-100 text-gray-800", +};