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 (
+
+ );
+}
+
+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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
+
+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 */}
+
+
+ {/* 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",
+};