feat: Implement complete APU (Análisis de Precios Unitarios) module

High priority features:
- APU CRUD with materials, labor, and equipment breakdown
- Labor catalog with FSR (Factor de Salario Real) calculation
- Equipment catalog with hourly cost calculation
- Link APU to budget line items (partidas)
- Explosion de insumos (consolidated materials list)

Additional features:
- Duplicate APU functionality
- Excel export for explosion de insumos
- Search and filters in APU list
- Price validation alerts for outdated prices
- PDF report export for APU

New components:
- APUForm, APUList, APUDetail
- ManoObraForm, EquipoForm
- ConfiguracionAPUForm
- VincularAPUDialog
- PartidasManager
- ExplosionInsumos
- APUPDF

New UI components:
- Alert component
- Tooltip component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mexus
2026-02-05 07:14:14 +00:00
parent e1847597d6
commit 56e39af3ff
47 changed files with 7779 additions and 18 deletions

106
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

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

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const { apu, materiales, categoriasManoObra, equipos, configuracion } =
await getData(session.user.empresaId, id);
if (!apu) {
notFound();
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Editar APU</h1>
<p className="text-slate-600">
Modificando {apu.codigo} - {apu.descripcion}
</p>
</div>
<APUForm
apu={apu}
materiales={materiales}
categoriasManoObra={categoriasManoObra}
equipos={equipos}
configuracion={configuracion}
/>
</div>
);
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const apu = await getAPU(session.user.empresaId, id);
if (!apu) {
notFound();
}
return <APUDetail apu={apu} />;
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const configuracion = await getConfiguracion(session.user.empresaId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Configuracion de APU</h1>
<p className="text-slate-600">
Define los porcentajes por defecto para nuevos analisis de precios
unitarios
</p>
</div>
<ConfiguracionAPUForm configuracion={configuracion} />
</div>
);
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const equipo = await getEquipo(session.user.empresaId, id);
if (!equipo) {
notFound();
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Editar Equipo</h1>
<p className="text-slate-600">
Modificando {equipo.codigo} - {equipo.nombre}
</p>
</div>
<EquipoForm equipo={equipo} />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { EquipoForm } from "@/components/apu";
export default function NuevoEquipoPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Nuevo Equipo o Maquinaria</h1>
<p className="text-slate-600">
Registra un nuevo equipo con calculo automatico de costo horario
</p>
</div>
<EquipoForm />
</div>
);
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const equipos = await getEquipos(session.user.empresaId);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Catalogo de Equipos y Maquinaria</h1>
<p className="text-slate-600">
Gestiona los equipos con calculo automatico de costo horario
</p>
</div>
<Button asChild>
<Link href="/apu/equipos/nuevo">
<Plus className="mr-2 h-4 w-4" />
Nuevo Equipo
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="h-5 w-5" />
Equipos y Maquinaria
</CardTitle>
<CardDescription>
Lista de equipos con su costo horario calculado
</CardDescription>
</CardHeader>
<CardContent>
{equipos.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-slate-500">
<Wrench className="mx-auto mb-4 h-12 w-12 text-slate-300" />
<p className="mb-4">No hay equipos registrados</p>
<Button asChild>
<Link href="/apu/equipos/nuevo">Crear primer equipo</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Codigo</TableHead>
<TableHead>Nombre</TableHead>
<TableHead>Tipo</TableHead>
<TableHead className="text-right">Valor Adquisicion</TableHead>
<TableHead className="text-right">Vida Util (hrs)</TableHead>
<TableHead className="text-right">Costo Horario</TableHead>
<TableHead className="text-center">En uso</TableHead>
<TableHead className="w-[80px]">Estado</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{equipos.map((equipo) => (
<TableRow key={equipo.id}>
<TableCell className="font-medium">{equipo.codigo}</TableCell>
<TableCell>{equipo.nombre}</TableCell>
<TableCell>
<Badge className={TIPO_EQUIPO_COLORS[equipo.tipo]}>
{TIPO_EQUIPO_LABELS[equipo.tipo]}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
${equipo.valorAdquisicion.toLocaleString("es-MX")}
</TableCell>
<TableCell className="text-right font-mono">
{equipo.vidaUtilHoras.toLocaleString("es-MX")}
</TableCell>
<TableCell className="text-right font-mono font-semibold text-green-600">
${equipo.costoHorario.toFixed(2)}/hr
</TableCell>
<TableCell className="text-center">
{equipo._count.insumosAPU > 0 ? (
<Badge variant="secondary">
{equipo._count.insumosAPU} APU
{equipo._count.insumosAPU !== 1 ? "s" : ""}
</Badge>
) : (
<span className="text-slate-400">-</span>
)}
</TableCell>
<TableCell>
<Badge
variant={equipo.activo ? "default" : "secondary"}
className={
equipo.activo
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{equipo.activo ? "Activo" : "Inactivo"}
</Badge>
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" asChild>
<Link href={`/apu/equipos/${equipo.id}/editar`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const categoria = await getCategoria(session.user.empresaId, id);
if (!categoria) {
notFound();
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Editar Categoria de Mano de Obra</h1>
<p className="text-slate-600">
Modificando {categoria.codigo} - {categoria.nombre}
</p>
</div>
<ManoObraForm categoria={categoria} />
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { ManoObraForm } from "@/components/apu";
export default function NuevaManoObraPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Nueva Categoria de Mano de Obra</h1>
<p className="text-slate-600">
Registra una nueva categoria de trabajador con su salario y factores
de FSR
</p>
</div>
<ManoObraForm />
</div>
);
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const categorias = await getCategorias(session.user.empresaId);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Catalogo de Mano de Obra</h1>
<p className="text-slate-600">
Gestiona las categorias de trabajadores con Factor de Salario Real
</p>
</div>
<Button asChild>
<Link href="/apu/mano-obra/nuevo">
<Plus className="mr-2 h-4 w-4" />
Nueva Categoria
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Categorias de Trabajo
</CardTitle>
<CardDescription>
Lista de categorias con salario diario, FSR y salario real calculado
</CardDescription>
</CardHeader>
<CardContent>
{categorias.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-slate-500">
<Users className="mx-auto mb-4 h-12 w-12 text-slate-300" />
<p className="mb-4">No hay categorias de mano de obra registradas</p>
<Button asChild>
<Link href="/apu/mano-obra/nuevo">Crear primera categoria</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Codigo</TableHead>
<TableHead>Nombre</TableHead>
<TableHead>Categoria</TableHead>
<TableHead className="text-right">Salario Diario</TableHead>
<TableHead className="text-right">FSR</TableHead>
<TableHead className="text-right">Salario Real</TableHead>
<TableHead className="text-center">En uso</TableHead>
<TableHead className="w-[80px]">Estado</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categorias.map((cat) => (
<TableRow key={cat.id}>
<TableCell className="font-medium">{cat.codigo}</TableCell>
<TableCell>{cat.nombre}</TableCell>
<TableCell>
{CATEGORIA_MANO_OBRA_LABELS[cat.categoria]}
</TableCell>
<TableCell className="text-right font-mono">
${cat.salarioDiario.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono">
{cat.factorSalarioReal.toFixed(4)}
</TableCell>
<TableCell className="text-right font-mono font-semibold text-green-600">
${cat.salarioReal.toFixed(2)}
</TableCell>
<TableCell className="text-center">
{cat._count.insumosAPU > 0 ? (
<Badge variant="secondary">
{cat._count.insumosAPU} APU{cat._count.insumosAPU !== 1 ? "s" : ""}
</Badge>
) : (
<span className="text-slate-400">-</span>
)}
</TableCell>
<TableCell>
<Badge
variant={cat.activo ? "default" : "secondary"}
className={
cat.activo
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{cat.activo ? "Activo" : "Inactivo"}
</Badge>
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" asChild>
<Link href={`/apu/mano-obra/${cat.id}/editar`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">
{apuDuplicar ? "Duplicar APU" : "Nuevo Analisis de Precio Unitario"}
</h1>
<p className="text-slate-600">
{apuDuplicar
? `Creando copia de ${apuDuplicar.codigo}`
: "Crea un nuevo APU con desglose de materiales, mano de obra y equipo"}
</p>
</div>
<APUForm
apu={apuData as Parameters<typeof APUForm>[0]["apu"]}
materiales={materiales}
categoriasManoObra={categoriasManoObra}
equipos={equipos}
configuracion={configuracion}
/>
</div>
);
}

View File

@@ -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 <div>Error: No se encontro la empresa</div>;
}
const apus = await getAPUs(session.user.empresaId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Analisis de Precios Unitarios</h1>
<p className="text-slate-600">
Gestiona los APUs para tus presupuestos de obra
</p>
</div>
<APUList apus={apus} />
</div>
);
}

View File

@@ -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 = () => (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
// Importaciones dinámicas para componentes pesados
const GaleriaFotos = dynamic(
() => import("@/components/fotos/galeria-fotos").then((mod) => mod.GaleriaFotos),
{ loading: () => <LoadingSpinner />, ssr: false }
);
const BitacoraObra = dynamic(
() => import("@/components/bitacora/bitacora-obra").then((mod) => mod.BitacoraObra),
{ loading: () => <LoadingSpinner />, ssr: false }
);
const ControlAsistencia = dynamic(
() => import("@/components/asistencia/control-asistencia").then((mod) => mod.ControlAsistencia),
{ loading: () => <LoadingSpinner />, ssr: false }
);
const OrdenesCompra = dynamic(
() => import("@/components/ordenes/ordenes-compra").then((mod) => mod.OrdenesCompra),
{ loading: () => <LoadingSpinner />, ssr: false }
);
const DiagramaGantt = dynamic(
() => import("@/components/gantt/diagrama-gantt").then((mod) => mod.DiagramaGantt),
{ loading: () => <LoadingSpinner />, 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) {
</CardContent>
</Card>
) : (
<div className="space-y-4">
<div className="space-y-6">
{obra.presupuestos.map((presupuesto) => (
<Card key={presupuesto.id}>
<CardHeader>
@@ -539,18 +581,33 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
{presupuesto.partidas.length} partidas
</CardDescription>
</div>
<div className="text-right">
<p className="text-xl font-bold">
{formatCurrency(presupuesto.total)}
</p>
<Badge
variant={presupuesto.aprobado ? "default" : "outline"}
>
{presupuesto.aprobado ? "Aprobado" : "Pendiente"}
</Badge>
<div className="flex items-center gap-4">
<ExplosionInsumos
presupuestoId={presupuesto.id}
presupuestoNombre={presupuesto.nombre}
/>
<div className="text-right">
<p className="text-xl font-bold">
{formatCurrency(presupuesto.total)}
</p>
<Badge
variant={presupuesto.aprobado ? "default" : "outline"}
>
{presupuesto.aprobado ? "Aprobado" : "Pendiente"}
</Badge>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<PartidasManager
presupuestoId={presupuesto.id}
presupuestoNombre={presupuesto.nombre}
partidas={presupuesto.partidas}
total={presupuesto.total}
aprobado={presupuesto.aprobado}
/>
</CardContent>
</Card>
))}
</div>

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

219
src/app/api/apu/route.ts Normal file
View File

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

View File

@@ -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<string, InsumoConsolidado>();
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 }
);
}
}

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/apu">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{apu.codigo}</h1>
<Badge
variant={apu.activo ? "default" : "secondary"}
className={
apu.activo
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{apu.activo ? "Activo" : "Inactivo"}
</Badge>
</div>
<p className="text-slate-600">{apu.descripcion}</p>
</div>
</div>
<div className="flex gap-2">
<ExportPDFButton
document={<APUPDF apu={apu} />}
fileName={`apu-${apu.codigo.toLowerCase().replace(/\s+/g, "-")}`}
>
Exportar PDF
</ExportPDFButton>
<Button
variant="outline"
onClick={handleDuplicate}
disabled={isDuplicating}
>
{isDuplicating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
Duplicar
</Button>
<Button variant="outline" asChild>
<Link href={`/apu/${apu.id}/editar`}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Link>
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={apu.partidas.length > 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</Button>
</div>
</div>
{/* Outdated Prices Alert */}
{hasOutdatedPrices && (
<Alert variant="destructive" className="border-yellow-500 bg-yellow-50">
<AlertTriangle className="h-4 w-4 text-yellow-600" />
<AlertTitle className="text-yellow-800">Precios Desactualizados</AlertTitle>
<AlertDescription className="text-yellow-700">
<p>
{outdatedPrices.length} insumo{outdatedPrices.length !== 1 ? "s tienen" : " tiene"} precios
diferentes al catalogo actual. Los precios marcados con{" "}
<AlertTriangle className="inline h-3 w-3 text-yellow-600" /> estan desactualizados.
</p>
<Button
variant="outline"
size="sm"
className="mt-2 border-yellow-600 text-yellow-700 hover:bg-yellow-100"
asChild
>
<Link href={`/apu/${apu.id}/editar`}>
<RefreshCw className="mr-2 h-4 w-4" />
Actualizar Precios
</Link>
</Button>
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
Unidad
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{UNIDAD_MEDIDA_LABELS[apu.unidad]}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
Rendimiento Diario
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{apu.rendimientoDiario
? `${apu.rendimientoDiario} ${UNIDAD_MEDIDA_LABELS[apu.unidad]}/jornada`
: "-"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
Precio Unitario
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-green-600">
${apu.precioUnitario.toFixed(2)}
</p>
</CardContent>
</Card>
</div>
{/* Materiales */}
{materialesInsumos.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Materiales
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="text-right">Cantidad</TableHead>
<TableHead className="text-right">Desp. %</TableHead>
<TableHead className="text-right">Cant. c/Desp.</TableHead>
<TableHead>Unidad</TableHead>
<TableHead className="text-right">P.U.</TableHead>
<TableHead className="text-right">Importe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materialesInsumos.map((insumo) => (
<TableRow key={insumo.id} className={isPriceOutdated(insumo) ? "bg-yellow-50" : ""}>
<TableCell className="font-medium">
{insumo.material?.codigo || "-"}
</TableCell>
<TableCell>{insumo.descripcion}</TableCell>
<TableCell className="text-right font-mono">
{insumo.cantidad.toFixed(4)}
</TableCell>
<TableCell className="text-right font-mono">
{insumo.desperdicio}%
</TableCell>
<TableCell className="text-right font-mono">
{insumo.cantidadConDesperdicio.toFixed(4)}
</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[insumo.unidad]}</TableCell>
<TableCell className="text-right font-mono">
{isPriceOutdated(insumo) ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 text-yellow-700">
<AlertTriangle className="h-3 w-3" />
${insumo.precioUnitario.toFixed(2)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Precio actual: ${getCurrentPrice(insumo)?.toFixed(2)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
`$${insumo.precioUnitario.toFixed(2)}`
)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
${insumo.importe.toFixed(2)}
</TableCell>
</TableRow>
))}
<TableRow className="bg-slate-50">
<TableCell colSpan={7} className="font-semibold">
Subtotal Materiales
</TableCell>
<TableCell className="text-right font-mono font-bold">
${apu.costoMateriales.toFixed(2)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Mano de Obra */}
{manoObraInsumos.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Mano de Obra
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="text-right">Cantidad</TableHead>
<TableHead className="text-right">Rendimiento</TableHead>
<TableHead className="text-right">Salario Real</TableHead>
<TableHead className="text-right">Importe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{manoObraInsumos.map((insumo) => (
<TableRow key={insumo.id} className={isPriceOutdated(insumo) ? "bg-yellow-50" : ""}>
<TableCell className="font-medium">
{insumo.categoriaManoObra?.codigo || "-"}
</TableCell>
<TableCell>{insumo.descripcion}</TableCell>
<TableCell className="text-right font-mono">
{insumo.cantidad}
</TableCell>
<TableCell className="text-right font-mono">
{insumo.rendimiento || "-"}
</TableCell>
<TableCell className="text-right font-mono">
{isPriceOutdated(insumo) ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 text-yellow-700">
<AlertTriangle className="h-3 w-3" />
${insumo.precioUnitario.toFixed(2)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Salario actual: ${getCurrentPrice(insumo)?.toFixed(2)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
`$${insumo.precioUnitario.toFixed(2)}`
)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
${insumo.importe.toFixed(2)}
</TableCell>
</TableRow>
))}
<TableRow className="bg-slate-50">
<TableCell colSpan={5} className="font-semibold">
Subtotal Mano de Obra
</TableCell>
<TableCell className="text-right font-mono font-bold">
${apu.costoManoObra.toFixed(2)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Equipo */}
{equipoInsumos.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="h-5 w-5" />
Equipo y Maquinaria
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="text-right">Horas</TableHead>
<TableHead className="text-right">Costo Horario</TableHead>
<TableHead className="text-right">Importe</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{equipoInsumos.map((insumo) => (
<TableRow key={insumo.id} className={isPriceOutdated(insumo) ? "bg-yellow-50" : ""}>
<TableCell className="font-medium">
{insumo.equipo?.codigo || "-"}
</TableCell>
<TableCell>{insumo.descripcion}</TableCell>
<TableCell className="text-right font-mono">
{insumo.cantidad}
</TableCell>
<TableCell className="text-right font-mono">
{isPriceOutdated(insumo) ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 text-yellow-700">
<AlertTriangle className="h-3 w-3" />
${insumo.precioUnitario.toFixed(2)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Costo horario actual: ${getCurrentPrice(insumo)?.toFixed(2)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
`$${insumo.precioUnitario.toFixed(2)}`
)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
${insumo.importe.toFixed(2)}
</TableCell>
</TableRow>
))}
<TableRow className="bg-slate-50">
<TableCell colSpan={4} className="font-semibold">
Subtotal Equipo
</TableCell>
<TableCell className="text-right font-mono font-bold">
${apu.costoEquipo.toFixed(2)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Resumen de Costos */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Resumen de Costos
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-slate-50 p-4">
<div className="space-y-2">
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Materiales:</span>
<span className="font-mono">${apu.costoMateriales.toFixed(2)}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Mano de Obra:</span>
<span className="font-mono">${apu.costoManoObra.toFixed(2)}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Equipo:</span>
<span className="font-mono">${apu.costoEquipo.toFixed(2)}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Herramienta Menor:</span>
<span className="font-mono">${apu.costoHerramienta.toFixed(2)}</span>
</div>
<div className="flex justify-between border-b pb-2 font-semibold">
<span>Costo Directo:</span>
<span className="font-mono">${apu.costoDirecto.toFixed(2)}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">
Indirectos ({apu.porcentajeIndirectos}%):
</span>
<span className="font-mono">${apu.costoIndirectos.toFixed(2)}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">
Utilidad ({apu.porcentajeUtilidad}%):
</span>
<span className="font-mono">${apu.costoUtilidad.toFixed(2)}</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-lg font-bold">Precio Unitario:</span>
<span className="font-mono text-xl font-bold text-green-600">
${apu.precioUnitario.toFixed(2)}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Partidas Vinculadas */}
{apu.partidas.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LinkIcon className="h-5 w-5" />
Partidas Vinculadas
</CardTitle>
<CardDescription>
Este APU esta siendo utilizado en las siguientes partidas de presupuesto
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Obra</TableHead>
<TableHead>Presupuesto</TableHead>
<TableHead>Codigo Partida</TableHead>
<TableHead>Descripcion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apu.partidas.map((partida) => (
<TableRow key={partida.id}>
<TableCell>
<Link
href={`/obras/${partida.presupuesto.obra.id}`}
className="text-blue-600 hover:underline"
>
{partida.presupuesto.obra.nombre}
</Link>
</TableCell>
<TableCell>{partida.presupuesto.nombre}</TableCell>
<TableCell className="font-medium">{partida.codigo}</TableCell>
<TableCell>{partida.descripcion}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar APU</AlertDialogTitle>
<AlertDialogDescription>
Esta accion no se puede deshacer. El analisis de precio unitario
sera eliminado permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -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<APUInput>({
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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informacion General</CardTitle>
<CardDescription>
Datos basicos del analisis de precio unitario
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="codigo">Codigo *</Label>
<Input
id="codigo"
placeholder="Ej: APU-001"
{...register("codigo")}
/>
{errors.codigo && (
<p className="text-sm text-red-600">{errors.codigo.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Unidad *</Label>
<Select
value={watchedValues.unidad}
onValueChange={(value) => setValue("unidad", value as UnidadMedida)}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar unidad" />
</SelectTrigger>
<SelectContent>
{Object.entries(UNIDAD_MEDIDA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rendimientoDiario">Rendimiento Diario</Label>
<Input
id="rendimientoDiario"
type="number"
step="0.01"
placeholder="Unidades por jornada"
{...register("rendimientoDiario", { valueAsNumber: true })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">Descripcion *</Label>
<Input
id="descripcion"
placeholder="Ej: Suministro y colocacion de muro de block de 15cm"
{...register("descripcion")}
/>
{errors.descripcion && (
<p className="text-sm text-red-600">{errors.descripcion.message}</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Desglose de Insumos</CardTitle>
<CardDescription>
Materiales, mano de obra y equipo que componen el precio unitario
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="materiales" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="materiales" className="flex items-center gap-2">
<Package className="h-4 w-4" />
Materiales ({materialesInsumos.length})
</TabsTrigger>
<TabsTrigger value="mano-obra" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Mano de Obra ({manoObraInsumos.length})
</TabsTrigger>
<TabsTrigger value="equipo" className="flex items-center gap-2">
<Wrench className="h-4 w-4" />
Equipo ({equipoInsumos.length})
</TabsTrigger>
</TabsList>
<TabsContent value="materiales" className="space-y-4">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addInsumo("MATERIAL")}
>
<Plus className="mr-2 h-4 w-4" />
Agregar Material
</Button>
</div>
<InsumoTable
fields={fields}
watchedValues={watchedValues}
register={register}
setValue={setValue}
remove={remove}
tipo="MATERIAL"
catalogo={materiales}
onCatalogoSelect={handleMaterialSelect}
catalogoLabel="Seleccionar material"
/>
</TabsContent>
<TabsContent value="mano-obra" className="space-y-4">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addInsumo("MANO_OBRA")}
>
<Plus className="mr-2 h-4 w-4" />
Agregar Mano de Obra
</Button>
</div>
<InsumoTable
fields={fields}
watchedValues={watchedValues}
register={register}
setValue={setValue}
remove={remove}
tipo="MANO_OBRA"
catalogo={categoriasManoObra}
onCatalogoSelect={handleManoObraSelect}
catalogoLabel="Seleccionar categoria"
showRendimiento
/>
</TabsContent>
<TabsContent value="equipo" className="space-y-4">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addInsumo("EQUIPO")}
>
<Plus className="mr-2 h-4 w-4" />
Agregar Equipo
</Button>
</div>
<InsumoTable
fields={fields}
watchedValues={watchedValues}
register={register}
setValue={setValue}
remove={remove}
tipo="EQUIPO"
catalogo={equipos}
onCatalogoSelect={handleEquipoSelect}
catalogoLabel="Seleccionar equipo"
/>
</TabsContent>
</Tabs>
{errors.insumos && (
<p className="mt-2 text-sm text-red-600">
{errors.insumos.message || "Se requiere al menos un insumo"}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Porcentajes de Indirectos y Utilidad</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="porcentajeIndirectos">% Indirectos</Label>
<Input
id="porcentajeIndirectos"
type="number"
step="0.01"
{...register("porcentajeIndirectos", { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="porcentajeUtilidad">% Utilidad</Label>
<Input
id="porcentajeUtilidad"
type="number"
step="0.01"
{...register("porcentajeUtilidad", { valueAsNumber: true })}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Resumen de Costos</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-slate-50 p-4">
<div className="space-y-2">
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Materiales:</span>
<span className="font-mono">
${calculatedTotals.costoMateriales.toFixed(2)}
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Mano de Obra:</span>
<span className="font-mono">
${calculatedTotals.costoManoObra.toFixed(2)}
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Equipo:</span>
<span className="font-mono">
${calculatedTotals.costoEquipo.toFixed(2)}
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">
Herramienta Menor ({configuracion.porcentajeHerramientaMenor}% M.O.):
</span>
<span className="font-mono">
${calculatedTotals.costoHerramienta.toFixed(2)}
</span>
</div>
<div className="flex justify-between border-b pb-2 font-semibold">
<span>Costo Directo:</span>
<span className="font-mono">
${calculatedTotals.costoDirecto.toFixed(2)}
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">
Indirectos ({watchedValues.porcentajeIndirectos || 0}%):
</span>
<span className="font-mono">
${calculatedTotals.costoIndirectos.toFixed(2)}
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">
Utilidad ({watchedValues.porcentajeUtilidad || 0}%):
</span>
<span className="font-mono">
${calculatedTotals.costoUtilidad.toFixed(2)}
</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-lg font-bold">Precio Unitario:</span>
<span className="font-mono text-xl font-bold text-green-600">
${calculatedTotals.precioUnitario.toFixed(2)}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancelar
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Guardar Cambios" : "Crear APU"}
</Button>
</div>
</form>
);
}
interface InsumoTableProps {
fields: { id: string }[];
watchedValues: APUInput;
register: ReturnType<typeof useForm<APUInput>>["register"];
setValue: ReturnType<typeof useForm<APUInput>>["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 (
<div className="rounded-lg border border-dashed p-8 text-center text-slate-500">
No hay {TIPO_INSUMO_APU_LABELS[tipo].toLowerCase()} agregados
</div>
);
}
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Catalogo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="w-[100px]">Cantidad</TableHead>
{tipo === "MATERIAL" && (
<TableHead className="w-[80px]">Desp. %</TableHead>
)}
{showRendimiento && (
<TableHead className="w-[100px]">Rendimiento</TableHead>
)}
<TableHead className="w-[120px]">P.U.</TableHead>
<TableHead className="w-[120px] text-right">Importe</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFields.map(({ field, index }) => {
const insumo = watchedValues.insumos?.[index];
const { importe } = calcularImporteInsumo({
tipo: insumo?.tipo || tipo,
cantidad: insumo?.cantidad || 0,
desperdicio: insumo?.desperdicio || 0,
rendimiento: insumo?.rendimiento,
precioUnitario: insumo?.precioUnitario || 0,
});
return (
<TableRow key={field.id}>
<TableCell>
<Select
value={
tipo === "MATERIAL"
? insumo?.materialId || ""
: tipo === "MANO_OBRA"
? insumo?.categoriaManoObraId || ""
: insumo?.equipoId || ""
}
onValueChange={(value) => onCatalogoSelect(index, value)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={catalogoLabel} />
</SelectTrigger>
<SelectContent>
{catalogo.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.codigo ? `${item.codigo} - ` : ""}
{item.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
className="h-8 text-sm"
{...register(`insumos.${index}.descripcion`)}
placeholder="Descripcion"
/>
</TableCell>
<TableCell>
<Input
type="number"
step="0.01"
className="h-8 text-sm"
{...register(`insumos.${index}.cantidad`, {
valueAsNumber: true,
})}
/>
</TableCell>
{tipo === "MATERIAL" && (
<TableCell>
<Input
type="number"
step="0.1"
className="h-8 text-sm"
{...register(`insumos.${index}.desperdicio`, {
valueAsNumber: true,
})}
/>
</TableCell>
)}
{showRendimiento && (
<TableCell>
<Input
type="number"
step="0.01"
className="h-8 text-sm"
placeholder="U/jor"
{...register(`insumos.${index}.rendimiento`, {
valueAsNumber: true,
})}
/>
</TableCell>
)}
<TableCell>
<Input
type="number"
step="0.01"
className="h-8 text-sm"
{...register(`insumos.${index}.precioUnitario`, {
valueAsNumber: true,
})}
/>
</TableCell>
<TableCell className="text-right font-mono">
${importe.toFixed(2)}
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -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<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [precioMinFilter, setPrecioMinFilter] = useState<string>("");
const [precioMaxFilter, setPrecioMaxFilter] = useState<string>("");
const [showFilters, setShowFilters] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Buscar por codigo o descripcion..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="relative"
>
<Filter className="mr-2 h-4 w-4" />
Filtros
{activeFiltersCount > 0 && (
<Badge
variant="secondary"
className="ml-2 h-5 w-5 rounded-full p-0 flex items-center justify-center bg-blue-500 text-white"
>
{activeFiltersCount}
</Badge>
)}
</Button>
<Button asChild>
<Link href="/apu/nuevo">
<Plus className="mr-2 h-4 w-4" />
Nuevo APU
</Link>
</Button>
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<div className="rounded-lg border bg-slate-50 p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm text-slate-700">Filtros</h3>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-8 text-xs"
>
<X className="mr-1 h-3 w-3" />
Limpiar filtros
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-1">
<label className="text-xs font-medium text-slate-600">
Unidad de Medida
</label>
<Select value={unidadFilter} onValueChange={setUnidadFilter}>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas</SelectItem>
{availableUnits.map((unit) => (
<SelectItem key={unit} value={unit}>
{UNIDAD_MEDIDA_LABELS[unit]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-600">
Estado
</label>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="active">Activos</SelectItem>
<SelectItem value="inactive">Inactivos</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-600">
Precio Minimo
</label>
<Input
type="number"
placeholder="$0.00"
value={precioMinFilter}
onChange={(e) => setPrecioMinFilter(e.target.value)}
min="0"
step="0.01"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-600">
Precio Maximo
</label>
<Input
type="number"
placeholder="Sin limite"
value={precioMaxFilter}
onChange={(e) => setPrecioMaxFilter(e.target.value)}
min="0"
step="0.01"
/>
</div>
</div>
<div className="text-xs text-slate-500">
Mostrando {filteredApus.length} de {apus.length} APUs
</div>
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px]">Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="w-[100px]">Unidad</TableHead>
<TableHead className="w-[150px] text-right">Precio Unitario</TableHead>
<TableHead className="w-[100px] text-center">Vinculado</TableHead>
<TableHead className="w-[80px]">Estado</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredApus.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
{search
? "No se encontraron resultados"
: "No hay APUs registrados"}
</TableCell>
</TableRow>
) : (
filteredApus.map((apu) => (
<TableRow key={apu.id}>
<TableCell className="font-medium">{apu.codigo}</TableCell>
<TableCell>
<Link
href={`/apu/${apu.id}`}
className="hover:text-blue-600 hover:underline"
>
{apu.descripcion}
</Link>
</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[apu.unidad]}</TableCell>
<TableCell className="text-right font-mono">
${apu.precioUnitario.toFixed(2)}
</TableCell>
<TableCell className="text-center">
{apu._count.partidas > 0 ? (
<Badge variant="secondary">
{apu._count.partidas} partida{apu._count.partidas !== 1 ? "s" : ""}
</Badge>
) : (
<span className="text-slate-400">-</span>
)}
</TableCell>
<TableCell>
<Badge
variant={apu.activo ? "default" : "secondary"}
className={
apu.activo
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{apu.activo ? "Activo" : "Inactivo"}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/apu/${apu.id}`}>
<Eye className="mr-2 h-4 w-4" />
Ver Detalle
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/apu/${apu.id}/editar`}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicate(apu)}>
<Copy className="mr-2 h-4 w-4" />
Duplicar
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => setDeleteId(apu.id)}
disabled={apu._count.partidas > 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar APU</AlertDialogTitle>
<AlertDialogDescription>
Esta accion no se puede deshacer. El analisis de precio unitario
sera eliminado permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -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<ConfiguracionAPUInput>({
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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Porcentajes por Defecto
</CardTitle>
<CardDescription>
Estos valores se usaran como predeterminados al crear nuevos APUs
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="porcentajeHerramientaMenor">
% Herramienta Menor (sobre Mano de Obra)
</Label>
<Input
id="porcentajeHerramientaMenor"
type="number"
step="0.1"
{...register("porcentajeHerramientaMenor", { valueAsNumber: true })}
/>
{errors.porcentajeHerramientaMenor && (
<p className="text-sm text-red-600">
{errors.porcentajeHerramientaMenor.message}
</p>
)}
<p className="text-sm text-slate-500">
Porcentaje del costo de mano de obra que se agrega como costo de
herramienta menor. Tipicamente entre 2% y 5%.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="porcentajeIndirectos">
% Costos Indirectos (sobre Costo Directo)
</Label>
<Input
id="porcentajeIndirectos"
type="number"
step="0.1"
{...register("porcentajeIndirectos", { valueAsNumber: true })}
/>
{errors.porcentajeIndirectos && (
<p className="text-sm text-red-600">
{errors.porcentajeIndirectos.message}
</p>
)}
<p className="text-sm text-slate-500">
Incluye gastos de administracion, supervision, seguros, etc.
Tipicamente entre 5% y 15%.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="porcentajeUtilidad">
% Utilidad (sobre Costo Directo + Indirectos)
</Label>
<Input
id="porcentajeUtilidad"
type="number"
step="0.1"
{...register("porcentajeUtilidad", { valueAsNumber: true })}
/>
{errors.porcentajeUtilidad && (
<p className="text-sm text-red-600">
{errors.porcentajeUtilidad.message}
</p>
)}
<p className="text-sm text-slate-500">
Margen de ganancia esperado. Tipicamente entre 8% y 15%.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Formula del Precio Unitario</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-slate-50 p-4 font-mono text-sm">
<p className="mb-2">
<strong>Costo Directo</strong> = Materiales + Mano de Obra + Equipo
+ Herramienta Menor
</p>
<p className="mb-2">
<strong>Herramienta Menor</strong> = Mano de Obra ×{" "}
{configuracion.porcentajeHerramientaMenor}%
</p>
<p className="mb-2">
<strong>Costos Indirectos</strong> = Costo Directo ×{" "}
{configuracion.porcentajeIndirectos}%
</p>
<p className="mb-2">
<strong>Utilidad</strong> = (Costo Directo + Indirectos) ×{" "}
{configuracion.porcentajeUtilidad}%
</p>
<p className="mt-4 border-t pt-2">
<strong>Precio Unitario</strong> = Costo Directo + Indirectos +
Utilidad
</p>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Guardar Configuracion
</Button>
</div>
</form>
);
}

View File

@@ -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<EquipoMaquinariaInput>({
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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informacion General</CardTitle>
<CardDescription>
Datos basicos del equipo o maquinaria
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="codigo">Codigo *</Label>
<Input
id="codigo"
placeholder="Ej: EQ-001"
{...register("codigo")}
/>
{errors.codigo && (
<p className="text-sm text-red-600">{errors.codigo.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nombre">Nombre *</Label>
<Input
id="nombre"
placeholder="Ej: Retroexcavadora CAT 420F"
{...register("nombre")}
/>
{errors.nombre && (
<p className="text-sm text-red-600">{errors.nombre.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label>Tipo de Equipo *</Label>
<Select
value={watchedValues.tipo}
onValueChange={(value) => setValue("tipo", value as TipoEquipo)}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar tipo" />
</SelectTrigger>
<SelectContent>
{Object.entries(TIPO_EQUIPO_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Datos Economicos</CardTitle>
<CardDescription>
Valores para calcular el costo horario del equipo
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="valorAdquisicion">Valor de Adquisicion *</Label>
<Input
id="valorAdquisicion"
type="number"
step="0.01"
placeholder="0.00"
{...register("valorAdquisicion", { valueAsNumber: true })}
/>
{errors.valorAdquisicion && (
<p className="text-sm text-red-600">
{errors.valorAdquisicion.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vidaUtilHoras">Vida Util (horas) *</Label>
<Input
id="vidaUtilHoras"
type="number"
placeholder="0"
{...register("vidaUtilHoras", { valueAsNumber: true })}
/>
{errors.vidaUtilHoras && (
<p className="text-sm text-red-600">
{errors.vidaUtilHoras.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="valorRescate">Valor de Rescate</Label>
<Input
id="valorRescate"
type="number"
step="0.01"
placeholder="0.00"
{...register("valorRescate", { valueAsNumber: true })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="factorMantenimiento">Factor de Mantenimiento</Label>
<Input
id="factorMantenimiento"
type="number"
step="0.01"
placeholder="0.60"
{...register("factorMantenimiento", { valueAsNumber: true })}
/>
<p className="text-xs text-slate-500">
Tipicamente entre 0.40 y 0.80 (60% por defecto)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="costoOperador">Costo Operador (por hora)</Label>
<Input
id="costoOperador"
type="number"
step="0.01"
placeholder="0.00"
{...register("costoOperador", { valueAsNumber: true })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="consumoCombustible">Consumo Combustible (Lt/hr)</Label>
<Input
id="consumoCombustible"
type="number"
step="0.01"
placeholder="0.00"
{...register("consumoCombustible", { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="precioCombustible">Precio Combustible ($/Lt)</Label>
<Input
id="precioCombustible"
type="number"
step="0.01"
placeholder="0.00"
{...register("precioCombustible", { valueAsNumber: true })}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Resumen de Costo Horario</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-slate-50 p-4">
<div className="space-y-2">
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Depreciacion:</span>
<span className="font-mono">
${costoBreakdown.depreciacion.toFixed(2)}/hr
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Mantenimiento:</span>
<span className="font-mono">
${costoBreakdown.mantenimiento.toFixed(2)}/hr
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Combustible:</span>
<span className="font-mono">
${costoBreakdown.combustible.toFixed(2)}/hr
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Operador:</span>
<span className="font-mono">
${costoBreakdown.operador.toFixed(2)}/hr
</span>
</div>
<div className="flex justify-between pt-2">
<span className="font-semibold">Costo Horario Total:</span>
<span className="font-mono text-lg font-bold text-green-600">
${calculatedCostoHorario.toFixed(2)}/hr
</span>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancelar
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Guardar Cambios" : "Crear Equipo"}
</Button>
</div>
</form>
);
}

View File

@@ -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";

View File

@@ -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<CategoriaManoObraInput>({
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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informacion General</CardTitle>
<CardDescription>
Datos basicos de la categoria de mano de obra
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="codigo">Codigo *</Label>
<Input
id="codigo"
placeholder="Ej: MO-001"
{...register("codigo")}
/>
{errors.codigo && (
<p className="text-sm text-red-600">{errors.codigo.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nombre">Nombre *</Label>
<Input
id="nombre"
placeholder="Ej: Peon de obra"
{...register("nombre")}
/>
{errors.nombre && (
<p className="text-sm text-red-600">{errors.nombre.message}</p>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Categoria *</Label>
<Select
value={watchedValues.categoria}
onValueChange={(value) =>
setValue("categoria", value as CategoriaManoObra)
}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar categoria" />
</SelectTrigger>
<SelectContent>
{Object.entries(CATEGORIA_MANO_OBRA_LABELS).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="salarioDiario">Salario Diario *</Label>
<Input
id="salarioDiario"
type="number"
step="0.01"
placeholder="0.00"
{...register("salarioDiario", { valueAsNumber: true })}
/>
{errors.salarioDiario && (
<p className="text-sm text-red-600">
{errors.salarioDiario.message}
</p>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Factor de Salario Real (FSR)</CardTitle>
<CardDescription>
Factores para calcular el salario real incluyendo prestaciones
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="factorIMSS">IMSS</Label>
<Input
id="factorIMSS"
type="number"
step="0.0001"
{...register("factorIMSS", { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="factorINFONAVIT">INFONAVIT</Label>
<Input
id="factorINFONAVIT"
type="number"
step="0.0001"
{...register("factorINFONAVIT", { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="factorRetiro">Retiro (SAR)</Label>
<Input
id="factorRetiro"
type="number"
step="0.0001"
{...register("factorRetiro", { valueAsNumber: true })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="factorVacaciones">Vacaciones</Label>
<Input
id="factorVacaciones"
type="number"
step="0.0001"
{...register("factorVacaciones", { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="factorPrimaVac">Prima Vacacional</Label>
<Input
id="factorPrimaVac"
type="number"
step="0.0001"
{...register("factorPrimaVac", { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="factorAguinaldo">Aguinaldo</Label>
<Input
id="factorAguinaldo"
type="number"
step="0.0001"
{...register("factorAguinaldo", { valueAsNumber: true })}
/>
</div>
</div>
<div className="mt-6 rounded-lg bg-slate-50 p-4">
<h4 className="mb-3 font-semibold">Resumen de Calculo</h4>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Factor de Salario Real:</span>
<span className="font-mono font-semibold">
{calculatedValues.fsr.toFixed(4)}
</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-slate-600">Salario Real Diario:</span>
<span className="font-mono font-semibold text-green-600">
${calculatedValues.salarioReal.toFixed(2)}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancelar
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Guardar Cambios" : "Crear Categoria"}
</Button>
</div>
</form>
);
}

View File

@@ -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<APU[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button type="button" variant="outline" size="sm">
<Link2 className="mr-2 h-4 w-4" />
Vincular APU
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Seleccionar APU
</DialogTitle>
<DialogDescription>
Selecciona un Analisis de Precio Unitario para vincular a esta partida
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Buscar por codigo o descripcion..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex-1 overflow-auto min-h-[300px]">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
</div>
) : error ? (
<div className="flex items-center justify-center h-full text-red-500">
{error}
</div>
) : filteredAPUs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-500">
<Calculator className="h-12 w-12 mb-4 text-slate-300" />
<p>
{search
? "No se encontraron APUs con ese criterio"
: "No hay APUs disponibles"}
</p>
{!search && (
<Button
variant="link"
className="mt-2"
onClick={() => window.open("/apu/nuevo", "_blank")}
>
Crear nuevo APU
</Button>
)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="w-[80px]">Unidad</TableHead>
<TableHead className="w-[120px] text-right">P.U.</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAPUs.map((apu) => (
<TableRow
key={apu.id}
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleSelect(apu)}
>
<TableCell className="font-medium">{apu.codigo}</TableCell>
<TableCell>{apu.descripcion}</TableCell>
<TableCell>
<Badge variant="outline">
{UNIDAD_MEDIDA_LABELS[apu.unidad]}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
${apu.precioUnitario.toFixed(2)}
</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleSelect(apu);
}}
>
Seleccionar
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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",

View File

@@ -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 (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Analisis de Precio Unitario</Text>
<Text style={styles.headerSubtitle}>{apu.codigo} - {apu.descripcion}</Text>
<Text style={styles.headerDate}>
Generado el {formatDatePDF(new Date())} | {empresaNombre}
</Text>
</View>
{/* Info General */}
<View style={[styles.section, { backgroundColor: "#f8fafc", padding: 12, borderRadius: 4 }]}>
<View style={styles.twoColumn}>
<View style={styles.column}>
<View style={styles.row}>
<Text style={styles.label}>Codigo:</Text>
<Text style={[styles.value, styles.textBold]}>{apu.codigo}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Descripcion:</Text>
<Text style={styles.value}>{apu.descripcion}</Text>
</View>
</View>
<View style={styles.column}>
<View style={styles.row}>
<Text style={styles.label}>Unidad:</Text>
<Text style={styles.value}>{UNIDAD_MEDIDA_LABELS[apu.unidad]}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Rendimiento:</Text>
<Text style={styles.value}>
{apu.rendimientoDiario
? `${apu.rendimientoDiario} ${UNIDAD_MEDIDA_LABELS[apu.unidad]}/jornada`
: "N/A"}
</Text>
</View>
</View>
</View>
</View>
{/* Materiales */}
{materialesInsumos.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Materiales</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: "10%" }]}>Codigo</Text>
<Text style={[styles.tableHeaderCell, { width: "30%" }]}>Descripcion</Text>
<Text style={[styles.tableHeaderCell, { width: "10%", textAlign: "right" }]}>Cant.</Text>
<Text style={[styles.tableHeaderCell, { width: "10%", textAlign: "right" }]}>Desp.%</Text>
<Text style={[styles.tableHeaderCell, { width: "10%", textAlign: "right" }]}>C/Desp.</Text>
<Text style={[styles.tableHeaderCell, { width: "8%" }]}>Unidad</Text>
<Text style={[styles.tableHeaderCell, { width: "12%", textAlign: "right" }]}>P.U.</Text>
<Text style={[styles.tableHeaderCell, { width: "10%", textAlign: "right" }]}>Importe</Text>
</View>
{materialesInsumos.map((insumo, index) => (
<View key={index} style={[styles.tableRow, index % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={[styles.tableCell, { width: "10%" }]}>
{insumo.material?.codigo || "-"}
</Text>
<Text style={[styles.tableCell, { width: "30%" }]}>{insumo.descripcion}</Text>
<Text style={[styles.tableCell, { width: "10%", textAlign: "right" }]}>
{insumo.cantidad.toFixed(4)}
</Text>
<Text style={[styles.tableCell, { width: "10%", textAlign: "right" }]}>
{insumo.desperdicio}%
</Text>
<Text style={[styles.tableCell, { width: "10%", textAlign: "right" }]}>
{insumo.cantidadConDesperdicio.toFixed(4)}
</Text>
<Text style={[styles.tableCell, { width: "8%" }]}>
{UNIDAD_MEDIDA_LABELS[insumo.unidad]}
</Text>
<Text style={[styles.tableCell, { width: "12%", textAlign: "right" }]}>
{formatCurrencyPDF(insumo.precioUnitario)}
</Text>
<Text style={[styles.tableCell, { width: "10%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(insumo.importe)}
</Text>
</View>
))}
<View style={[styles.tableRow, { backgroundColor: "#f1f5f9" }]}>
<Text style={[styles.tableCell, { width: "90%", fontFamily: "Helvetica-Bold" }]}>
Subtotal Materiales
</Text>
<Text style={[styles.tableCell, { width: "10%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(apu.costoMateriales)}
</Text>
</View>
</View>
</View>
)}
{/* Mano de Obra */}
{manoObraInsumos.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Mano de Obra</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: "15%" }]}>Codigo</Text>
<Text style={[styles.tableHeaderCell, { width: "40%" }]}>Descripcion</Text>
<Text style={[styles.tableHeaderCell, { width: "12%", textAlign: "right" }]}>Cantidad</Text>
<Text style={[styles.tableHeaderCell, { width: "12%", textAlign: "right" }]}>Rend.</Text>
<Text style={[styles.tableHeaderCell, { width: "12%", textAlign: "right" }]}>Salario</Text>
<Text style={[styles.tableHeaderCell, { width: "12%", textAlign: "right" }]}>Importe</Text>
</View>
{manoObraInsumos.map((insumo, index) => (
<View key={index} style={[styles.tableRow, index % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={[styles.tableCell, { width: "15%" }]}>
{insumo.categoriaManoObra?.codigo || "-"}
</Text>
<Text style={[styles.tableCell, { width: "40%" }]}>{insumo.descripcion}</Text>
<Text style={[styles.tableCell, { width: "12%", textAlign: "right" }]}>
{insumo.cantidad}
</Text>
<Text style={[styles.tableCell, { width: "12%", textAlign: "right" }]}>
{insumo.rendimiento || "-"}
</Text>
<Text style={[styles.tableCell, { width: "12%", textAlign: "right" }]}>
{formatCurrencyPDF(insumo.precioUnitario)}
</Text>
<Text style={[styles.tableCell, { width: "12%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(insumo.importe)}
</Text>
</View>
))}
<View style={[styles.tableRow, { backgroundColor: "#f1f5f9" }]}>
<Text style={[styles.tableCell, { width: "88%", fontFamily: "Helvetica-Bold" }]}>
Subtotal Mano de Obra
</Text>
<Text style={[styles.tableCell, { width: "12%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(apu.costoManoObra)}
</Text>
</View>
</View>
</View>
)}
{/* Equipo */}
{equipoInsumos.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Equipo y Maquinaria</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, { width: "15%" }]}>Codigo</Text>
<Text style={[styles.tableHeaderCell, { width: "45%" }]}>Descripcion</Text>
<Text style={[styles.tableHeaderCell, { width: "15%", textAlign: "right" }]}>Horas</Text>
<Text style={[styles.tableHeaderCell, { width: "15%", textAlign: "right" }]}>C. Horario</Text>
<Text style={[styles.tableHeaderCell, { width: "15%", textAlign: "right" }]}>Importe</Text>
</View>
{equipoInsumos.map((insumo, index) => (
<View key={index} style={[styles.tableRow, index % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={[styles.tableCell, { width: "15%" }]}>
{insumo.equipo?.codigo || "-"}
</Text>
<Text style={[styles.tableCell, { width: "45%" }]}>{insumo.descripcion}</Text>
<Text style={[styles.tableCell, { width: "15%", textAlign: "right" }]}>
{insumo.cantidad}
</Text>
<Text style={[styles.tableCell, { width: "15%", textAlign: "right" }]}>
{formatCurrencyPDF(insumo.precioUnitario)}
</Text>
<Text style={[styles.tableCell, { width: "15%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(insumo.importe)}
</Text>
</View>
))}
<View style={[styles.tableRow, { backgroundColor: "#f1f5f9" }]}>
<Text style={[styles.tableCell, { width: "85%", fontFamily: "Helvetica-Bold" }]}>
Subtotal Equipo
</Text>
<Text style={[styles.tableCell, { width: "15%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(apu.costoEquipo)}
</Text>
</View>
</View>
</View>
)}
{/* Resumen de Costos */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Resumen de Costos</Text>
<View style={[styles.card, { marginTop: 10 }]}>
<View style={[styles.row, { borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 4 }]}>
<Text style={[styles.label, { width: "60%" }]}>Materiales:</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right" }]}>
{formatCurrencyPDF(apu.costoMateriales)}
</Text>
</View>
<View style={[styles.row, { borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 4 }]}>
<Text style={[styles.label, { width: "60%" }]}>Mano de Obra:</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right" }]}>
{formatCurrencyPDF(apu.costoManoObra)}
</Text>
</View>
<View style={[styles.row, { borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 4 }]}>
<Text style={[styles.label, { width: "60%" }]}>Equipo:</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right" }]}>
{formatCurrencyPDF(apu.costoEquipo)}
</Text>
</View>
<View style={[styles.row, { borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 4 }]}>
<Text style={[styles.label, { width: "60%" }]}>Herramienta Menor:</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right" }]}>
{formatCurrencyPDF(apu.costoHerramienta)}
</Text>
</View>
<View style={[styles.row, { borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 4, marginTop: 4 }]}>
<Text style={[styles.label, { width: "60%", fontFamily: "Helvetica-Bold" }]}>Costo Directo:</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right", fontFamily: "Helvetica-Bold" }]}>
{formatCurrencyPDF(apu.costoDirecto)}
</Text>
</View>
<View style={[styles.row, { borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 4 }]}>
<Text style={[styles.label, { width: "60%" }]}>Indirectos ({apu.porcentajeIndirectos}%):</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right" }]}>
{formatCurrencyPDF(apu.costoIndirectos)}
</Text>
</View>
<View style={[styles.row, { borderBottomWidth: 1, borderBottomColor: "#e2e8f0", paddingBottom: 4 }]}>
<Text style={[styles.label, { width: "60%" }]}>Utilidad ({apu.porcentajeUtilidad}%):</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right" }]}>
{formatCurrencyPDF(apu.costoUtilidad)}
</Text>
</View>
<View style={[styles.row, { marginTop: 8, paddingTop: 8, borderTopWidth: 2, borderTopColor: "#1e40af" }]}>
<Text style={[styles.label, { width: "60%", fontSize: 12, fontFamily: "Helvetica-Bold", color: "#1e40af" }]}>
PRECIO UNITARIO:
</Text>
<Text style={[styles.value, { width: "40%", textAlign: "right", fontSize: 14, fontFamily: "Helvetica-Bold", color: "#1e40af" }]}>
{formatCurrencyPDF(apu.precioUnitario)}
</Text>
</View>
</View>
</View>
{/* Footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>{empresaNombre} - Sistema de Gestion de Obras</Text>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Pagina ${pageNumber} de ${totalPages}`} />
</View>
</Page>
</Document>
);
}

View File

@@ -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";

View File

@@ -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<ExplosionData | null>(null);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" size="sm">
<FileSpreadsheet className="mr-2 h-4 w-4" />
Explosion de Insumos
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Explosion de Insumos
</DialogTitle>
{data && data.partidasConAPU > 0 && (
<Button
variant="outline"
size="sm"
onClick={exportToExcel}
className="ml-4"
>
<Download className="mr-2 h-4 w-4" />
Exportar Excel
</Button>
)}
</div>
<DialogDescription>
Lista consolidada de todos los materiales, mano de obra y equipos
necesarios para {presupuestoNombre}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-64 text-red-500">
<AlertTriangle className="h-12 w-12 mb-4" />
<p>{error}</p>
<Button variant="outline" className="mt-4" onClick={fetchExplosion}>
Reintentar
</Button>
</div>
) : data ? (
<div className="flex-1 overflow-auto space-y-4">
{/* Summary Cards */}
<div className="grid grid-cols-4 gap-3">
<Card>
<CardHeader className="py-2 px-3">
<CardTitle className="text-xs font-medium text-slate-500">
Materiales
</CardTitle>
</CardHeader>
<CardContent className="py-2 px-3">
<p className="text-lg font-bold text-emerald-600">
{formatCurrency(data.totales.materiales)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="py-2 px-3">
<CardTitle className="text-xs font-medium text-slate-500">
Mano de Obra
</CardTitle>
</CardHeader>
<CardContent className="py-2 px-3">
<p className="text-lg font-bold text-blue-600">
{formatCurrency(data.totales.manoObra)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="py-2 px-3">
<CardTitle className="text-xs font-medium text-slate-500">
Equipos
</CardTitle>
</CardHeader>
<CardContent className="py-2 px-3">
<p className="text-lg font-bold text-orange-600">
{formatCurrency(data.totales.equipos)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="py-2 px-3">
<CardTitle className="text-xs font-medium text-slate-500">
Total
</CardTitle>
</CardHeader>
<CardContent className="py-2 px-3">
<p className="text-lg font-bold">
{formatCurrency(data.totales.total)}
</p>
</CardContent>
</Card>
</div>
{data.partidasConAPU === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
<AlertTriangle className="h-12 w-12 mb-4 text-yellow-500" />
<p className="text-center">
No hay partidas con APU vinculado en este presupuesto.
<br />
Vincula APUs a las partidas para ver la explosion de insumos.
</p>
</div>
) : (
<Tabs defaultValue="materiales" className="flex-1">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="materiales" className="flex items-center gap-2">
<Package className="h-4 w-4" />
Materiales ({data.explosion.materiales.length})
</TabsTrigger>
<TabsTrigger value="mano-obra" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Mano de Obra ({data.explosion.manoObra.length})
</TabsTrigger>
<TabsTrigger value="equipos" className="flex items-center gap-2">
<Wrench className="h-4 w-4" />
Equipos ({data.explosion.equipos.length})
</TabsTrigger>
</TabsList>
<TabsContent value="materiales" className="mt-4">
<InsumoTable
insumos={data.explosion.materiales}
formatCurrency={formatCurrency}
formatQuantity={formatQuantity}
emptyMessage="No hay materiales en las partidas con APU"
/>
</TabsContent>
<TabsContent value="mano-obra" className="mt-4">
<InsumoTable
insumos={data.explosion.manoObra}
formatCurrency={formatCurrency}
formatQuantity={formatQuantity}
emptyMessage="No hay mano de obra en las partidas con APU"
/>
</TabsContent>
<TabsContent value="equipos" className="mt-4">
<InsumoTable
insumos={data.explosion.equipos}
formatCurrency={formatCurrency}
formatQuantity={formatQuantity}
emptyMessage="No hay equipos en las partidas con APU"
/>
</TabsContent>
</Tabs>
)}
</div>
) : null}
</DialogContent>
</Dialog>
);
}
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 (
<div className="flex items-center justify-center h-32 text-slate-500">
{emptyMessage}
</div>
);
}
const total = insumos.reduce((sum, i) => sum + i.importeTotal, 0);
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="w-[80px]">Unidad</TableHead>
<TableHead className="w-[100px] text-right">Cantidad</TableHead>
<TableHead className="w-[100px] text-right">P.U.</TableHead>
<TableHead className="w-[120px] text-right">Importe</TableHead>
<TableHead className="w-[80px] text-center">Partidas</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insumos.map((insumo, index) => (
<TableRow key={index}>
<TableCell className="font-mono text-sm">
{insumo.codigo || "-"}
</TableCell>
<TableCell>{insumo.descripcion}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{UNIDAD_MEDIDA_LABELS[insumo.unidad]}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
{formatQuantity(insumo.cantidadTotal)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(insumo.precioUnitario)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(insumo.importeTotal)}
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{insumo.partidasCount}</Badge>
</TableCell>
</TableRow>
))}
<TableRow className="bg-slate-50 font-semibold">
<TableCell colSpan={5}>Total</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(total)}
</TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { PartidasManager } from "./partidas-manager";
export { ExplosionInsumos } from "./explosion-insumos";

View File

@@ -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<typeof partidaSchema>;
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<Partida | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [selectedAPU, setSelectedAPU] = useState<APU | null>(null);
const {
register,
handleSubmit,
formState: { errors },
setValue,
watch,
reset,
} = useForm<PartidaFormData>({
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{presupuestoNombre}</h3>
<p className="text-sm text-muted-foreground">
{partidas.length} partidas - Total: ${total.toLocaleString("es-MX", { minimumFractionDigits: 2 })}
</p>
</div>
<Button onClick={openCreateForm} disabled={aprobado}>
<Plus className="mr-2 h-4 w-4" />
Agregar Partida
</Button>
</div>
{partidas.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<Calculator className="mx-auto mb-4 h-12 w-12 text-slate-300" />
<p className="mb-4">No hay partidas en este presupuesto</p>
<Button onClick={openCreateForm} disabled={aprobado}>
Agregar primera partida
</Button>
</CardContent>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Codigo</TableHead>
<TableHead>Descripcion</TableHead>
<TableHead className="w-[80px]">Unidad</TableHead>
<TableHead className="w-[80px] text-right">Cant.</TableHead>
<TableHead className="w-[100px] text-right">P.U.</TableHead>
<TableHead className="w-[120px] text-right">Total</TableHead>
<TableHead className="w-[100px]">APU</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{partidas.map((partida) => (
<TableRow key={partida.id}>
<TableCell className="font-medium">{partida.codigo}</TableCell>
<TableCell>{partida.descripcion}</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[partida.unidad]}</TableCell>
<TableCell className="text-right font-mono">
{partida.cantidad.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono">
${partida.precioUnitario.toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
${partida.total.toFixed(2)}
</TableCell>
<TableCell>
{partida.apu ? (
<Badge
variant="outline"
className="cursor-pointer hover:bg-slate-100"
onClick={() => window.open(`/apu/${partida.apu!.id}`, "_blank")}
>
<Link2 className="mr-1 h-3 w-3" />
{partida.apu.codigo}
</Badge>
) : (
<span className="text-slate-400">-</span>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={aprobado}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEditForm(partida)}>
<Pencil className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => setDeleteId(partida.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
<TableRow className="bg-slate-50 font-semibold">
<TableCell colSpan={5}>Total Presupuesto</TableCell>
<TableCell className="text-right font-mono">
${total.toLocaleString("es-MX", { minimumFractionDigits: 2 })}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
{/* Form Dialog */}
<Dialog open={showForm} onOpenChange={setShowForm}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{editingPartida ? "Editar Partida" : "Nueva Partida"}
</DialogTitle>
<DialogDescription>
{editingPartida
? "Modifica los datos de la partida"
: "Agrega una nueva partida al presupuesto"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* APU Link */}
<div className="rounded-lg border p-3 bg-slate-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calculator className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium">
{selectedAPU ? "APU Vinculado" : "Sin APU vinculado"}
</span>
</div>
<div className="flex gap-2">
{selectedAPU ? (
<>
<Badge variant="outline">{selectedAPU.codigo}</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleUnlinkAPU}
>
<Unlink className="h-4 w-4" />
</Button>
</>
) : (
<VincularAPUDialog onSelect={handleAPUSelect} />
)}
</div>
</div>
{selectedAPU && (
<p className="text-xs text-slate-500 mt-1">
{selectedAPU.descripcion} - ${selectedAPU.precioUnitario.toFixed(2)}
</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="codigo">Codigo *</Label>
<Input
id="codigo"
placeholder="Ej: PAR-001"
{...register("codigo")}
/>
{errors.codigo && (
<p className="text-sm text-red-600">{errors.codigo.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Categoria *</Label>
<Select
value={watchedValues.categoria}
onValueChange={(value) => setValue("categoria", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(CATEGORIA_GASTO_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">Descripcion *</Label>
<Input
id="descripcion"
placeholder="Descripcion de la partida"
{...register("descripcion")}
/>
{errors.descripcion && (
<p className="text-sm text-red-600">{errors.descripcion.message}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>Unidad *</Label>
<Select
value={watchedValues.unidad}
onValueChange={(value) => setValue("unidad", value)}
disabled={!!selectedAPU}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(UNIDAD_MEDIDA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cantidad">Cantidad *</Label>
<Input
id="cantidad"
type="number"
step="0.01"
{...register("cantidad", { valueAsNumber: true })}
/>
{errors.cantidad && (
<p className="text-sm text-red-600">{errors.cantidad.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="precioUnitario">P.U. *</Label>
<Input
id="precioUnitario"
type="number"
step="0.01"
{...register("precioUnitario", { valueAsNumber: true })}
disabled={!!selectedAPU}
/>
{errors.precioUnitario && (
<p className="text-sm text-red-600">{errors.precioUnitario.message}</p>
)}
</div>
</div>
<div className="rounded-lg bg-slate-100 p-3">
<div className="flex justify-between">
<span className="font-medium">Total:</span>
<span className="font-mono font-bold text-green-600">
${calculatedTotal.toLocaleString("es-MX", { minimumFractionDigits: 2 })}
</span>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setShowForm(false)}
disabled={isLoading}
>
Cancelar
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingPartida ? "Guardar" : "Agregar"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar Partida</AlertDialogTitle>
<AlertDialogDescription>
Esta accion no se puede deshacer. La partida sera eliminada
permanentemente del presupuesto.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isLoading}
className="bg-red-600 hover:bg-red-700"
>
{isLoading ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -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<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -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"),

259
src/lib/validations/apu.ts Normal file
View File

@@ -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<typeof categoriaManoObraSchema>;
export type CategoriaManoObraUpdateInput = z.infer<typeof categoriaManoObraUpdateSchema>;
export type EquipoMaquinariaInput = z.infer<typeof equipoMaquinariaSchema>;
export type EquipoMaquinariaUpdateInput = z.infer<typeof equipoMaquinariaUpdateSchema>;
export type InsumoAPUInput = z.infer<typeof insumoAPUSchema>;
export type APUInput = z.infer<typeof apuSchema>;
export type APUUpdateInput = z.infer<typeof apuUpdateSchema>;
export type ConfiguracionAPUInput = z.infer<typeof configuracionAPUSchema>;
// 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,
};
}

View File

@@ -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<UnidadMedida, string> = {
PIEZA: "Pieza",
ROLLO: "Rollo",
CAJA: "Caja",
HORA: "Hora",
JORNADA: "Jornada",
VIAJE: "Viaje",
LOTE: "Lote",
GLOBAL: "Global",
};
export const ESTADO_FACTURA_LABELS: Record<EstadoFactura, string> = {
@@ -237,3 +248,46 @@ export const PRIORIDAD_ORDEN_COLORS: Record<PrioridadOrden, string> = {
ALTA: "bg-orange-100 text-orange-800",
URGENTE: "bg-red-100 text-red-800",
};
// ============== APU LABELS ==============
export const CATEGORIA_MANO_OBRA_LABELS: Record<CategoriaManoObra, string> = {
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<TipoEquipo, string> = {
MAQUINARIA_PESADA: "Maquinaria Pesada",
MAQUINARIA_LIGERA: "Maquinaria Ligera",
HERRAMIENTA_ELECTRICA: "Herramienta Electrica",
TRANSPORTE: "Transporte",
};
export const TIPO_EQUIPO_COLORS: Record<TipoEquipo, string> = {
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<TipoInsumoAPU, string> = {
MATERIAL: "Material",
MANO_OBRA: "Mano de Obra",
EQUIPO: "Equipo",
HERRAMIENTA_MENOR: "Herramienta Menor",
};
export const TIPO_INSUMO_APU_COLORS: Record<TipoInsumoAPU, string> = {
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",
};