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:
106
package-lock.json
generated
106
package-lock.json
generated
@@ -23,7 +23,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@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",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3236,6 +3237,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -3852,6 +3862,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -3941,6 +3964,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3986,6 +4018,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5273,6 +5317,15 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -8051,6 +8104,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -9072,6 +9137,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -9190,6 +9273,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@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",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -82,6 +82,38 @@ enum UnidadMedida {
|
|||||||
PIEZA
|
PIEZA
|
||||||
ROLLO
|
ROLLO
|
||||||
CAJA
|
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 ==============
|
// ============== MODELS ==============
|
||||||
@@ -137,6 +169,11 @@ model Empresa {
|
|||||||
empleados Empleado[]
|
empleados Empleado[]
|
||||||
subcontratistas Subcontratista[]
|
subcontratistas Subcontratista[]
|
||||||
clientes Cliente[]
|
clientes Cliente[]
|
||||||
|
// APU Relations
|
||||||
|
categoriasTrabajo CategoriaTrabajoAPU[]
|
||||||
|
equiposMaquinaria EquipoMaquinaria[]
|
||||||
|
apus AnalisisPrecioUnitario[]
|
||||||
|
configuracionAPU ConfiguracionAPU?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Cliente {
|
model Cliente {
|
||||||
@@ -267,6 +304,8 @@ model TareaObra {
|
|||||||
|
|
||||||
@@index([faseId])
|
@@index([faseId])
|
||||||
@@index([estado])
|
@@index([estado])
|
||||||
|
@@index([asignadoId])
|
||||||
|
@@index([faseId, estado])
|
||||||
}
|
}
|
||||||
|
|
||||||
model RegistroAvance {
|
model RegistroAvance {
|
||||||
@@ -281,6 +320,8 @@ model RegistroAvance {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([obraId])
|
@@index([obraId])
|
||||||
|
@@index([registradoPorId])
|
||||||
|
@@index([obraId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Presupuesto {
|
model Presupuesto {
|
||||||
@@ -312,6 +353,8 @@ model PartidaPresupuesto {
|
|||||||
categoria CategoriaGasto
|
categoria CategoriaGasto
|
||||||
presupuestoId String
|
presupuestoId String
|
||||||
presupuesto Presupuesto @relation(fields: [presupuestoId], references: [id], onDelete: Cascade)
|
presupuesto Presupuesto @relation(fields: [presupuestoId], references: [id], onDelete: Cascade)
|
||||||
|
apuId String?
|
||||||
|
apu AnalisisPrecioUnitario? @relation(fields: [apuId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -319,6 +362,7 @@ model PartidaPresupuesto {
|
|||||||
gastos Gasto[]
|
gastos Gasto[]
|
||||||
|
|
||||||
@@index([presupuestoId])
|
@@index([presupuestoId])
|
||||||
|
@@index([apuId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Gasto {
|
model Gasto {
|
||||||
@@ -395,10 +439,12 @@ model Material {
|
|||||||
// Relations
|
// Relations
|
||||||
movimientos MovimientoInventario[]
|
movimientos MovimientoInventario[]
|
||||||
itemsOrden ItemOrdenCompra[]
|
itemsOrden ItemOrdenCompra[]
|
||||||
|
insumosAPU InsumoAPU[]
|
||||||
|
|
||||||
@@unique([codigo, empresaId])
|
@@unique([codigo, empresaId])
|
||||||
@@index([empresaId])
|
@@index([empresaId])
|
||||||
@@index([nombre])
|
@@index([nombre])
|
||||||
|
@@index([activo, empresaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model MovimientoInventario {
|
model MovimientoInventario {
|
||||||
@@ -470,6 +516,7 @@ model JornadaTrabajo {
|
|||||||
|
|
||||||
@@index([empleadoId])
|
@@index([empleadoId])
|
||||||
@@index([fecha])
|
@@index([fecha])
|
||||||
|
@@index([empleadoId, fecha])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Subcontratista {
|
model Subcontratista {
|
||||||
@@ -773,6 +820,8 @@ model FotoAvance {
|
|||||||
@@index([obraId])
|
@@index([obraId])
|
||||||
@@index([faseId])
|
@@index([faseId])
|
||||||
@@index([fechaCaptura])
|
@@index([fechaCaptura])
|
||||||
|
@@index([subidoPorId])
|
||||||
|
@@index([obraId, fechaCaptura])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== NOTIFICACIONES PUSH ==============
|
// ============== NOTIFICACIONES PUSH ==============
|
||||||
@@ -894,4 +943,134 @@ model ActividadLog {
|
|||||||
@@index([empresaId])
|
@@index([empresaId])
|
||||||
@@index([tipo])
|
@@index([tipo])
|
||||||
@@index([createdAt])
|
@@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
|
||||||
}
|
}
|
||||||
|
|||||||
99
src/app/(dashboard)/apu/[id]/editar/page.tsx
Normal file
99
src/app/(dashboard)/apu/[id]/editar/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/(dashboard)/apu/[id]/page.tsx
Normal file
52
src/app/(dashboard)/apu/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
46
src/app/(dashboard)/apu/configuracion/page.tsx
Normal file
46
src/app/(dashboard)/apu/configuracion/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/(dashboard)/apu/equipos/[id]/editar/page.tsx
Normal file
44
src/app/(dashboard)/apu/equipos/[id]/editar/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/(dashboard)/apu/equipos/nuevo/page.tsx
Normal file
16
src/app/(dashboard)/apu/equipos/nuevo/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/app/(dashboard)/apu/equipos/page.tsx
Normal file
155
src/app/(dashboard)/apu/equipos/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/(dashboard)/apu/mano-obra/[id]/editar/page.tsx
Normal file
44
src/app/(dashboard)/apu/mano-obra/[id]/editar/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/(dashboard)/apu/mano-obra/nuevo/page.tsx
Normal file
17
src/app/(dashboard)/apu/mano-obra/nuevo/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/app/(dashboard)/apu/mano-obra/page.tsx
Normal file
152
src/app/(dashboard)/apu/mano-obra/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/(dashboard)/apu/nuevo/page.tsx
Normal file
109
src/app/(dashboard)/apu/nuevo/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/(dashboard)/apu/page.tsx
Normal file
40
src/app/(dashboard)/apu/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, lazy } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
@@ -43,17 +46,46 @@ import {
|
|||||||
type CategoriaGasto,
|
type CategoriaGasto,
|
||||||
type CondicionClima,
|
type CondicionClima,
|
||||||
} from "@/types";
|
} 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 {
|
import {
|
||||||
ExportPDFMenu,
|
ExportPDFMenu,
|
||||||
ReporteObraPDF,
|
ReporteObraPDF,
|
||||||
GastosPDF,
|
GastosPDF,
|
||||||
BitacoraPDF,
|
BitacoraPDF,
|
||||||
} from "@/components/pdf";
|
} 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 {
|
interface ObraDetailProps {
|
||||||
obra: {
|
obra: {
|
||||||
@@ -98,7 +130,17 @@ interface ObraDetailProps {
|
|||||||
id: string;
|
id: string;
|
||||||
codigo: string;
|
codigo: string;
|
||||||
descripcion: string;
|
descripcion: string;
|
||||||
|
unidad: import("@prisma/client").UnidadMedida;
|
||||||
|
cantidad: number;
|
||||||
|
precioUnitario: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
categoria: CategoriaGasto;
|
||||||
|
apu: {
|
||||||
|
id: string;
|
||||||
|
codigo: string;
|
||||||
|
descripcion: string;
|
||||||
|
precioUnitario: number;
|
||||||
|
} | null;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
gastos: {
|
gastos: {
|
||||||
@@ -526,7 +568,7 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{obra.presupuestos.map((presupuesto) => (
|
{obra.presupuestos.map((presupuesto) => (
|
||||||
<Card key={presupuesto.id}>
|
<Card key={presupuesto.id}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -539,18 +581,33 @@ export function ObraDetailClient({ obra }: ObraDetailProps) {
|
|||||||
{presupuesto.partidas.length} partidas
|
{presupuesto.partidas.length} partidas
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex items-center gap-4">
|
||||||
<p className="text-xl font-bold">
|
<ExplosionInsumos
|
||||||
{formatCurrency(presupuesto.total)}
|
presupuestoId={presupuesto.id}
|
||||||
</p>
|
presupuestoNombre={presupuesto.nombre}
|
||||||
<Badge
|
/>
|
||||||
variant={presupuesto.aprobado ? "default" : "outline"}
|
<div className="text-right">
|
||||||
>
|
<p className="text-xl font-bold">
|
||||||
{presupuesto.aprobado ? "Aprobado" : "Pendiente"}
|
{formatCurrency(presupuesto.total)}
|
||||||
</Badge>
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant={presupuesto.aprobado ? "default" : "outline"}
|
||||||
|
>
|
||||||
|
{presupuesto.aprobado ? "Aprobado" : "Pendiente"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PartidasManager
|
||||||
|
presupuestoId={presupuesto.id}
|
||||||
|
presupuestoNombre={presupuesto.nombre}
|
||||||
|
partidas={presupuesto.partidas}
|
||||||
|
total={presupuesto.total}
|
||||||
|
aprobado={presupuesto.aprobado}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,21 @@ async function getObra(id: string, empresaId: string) {
|
|||||||
orderBy: { orden: "asc" },
|
orderBy: { orden: "asc" },
|
||||||
},
|
},
|
||||||
presupuestos: {
|
presupuestos: {
|
||||||
include: { partidas: true },
|
include: {
|
||||||
|
partidas: {
|
||||||
|
include: {
|
||||||
|
apu: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
codigo: true,
|
||||||
|
descripcion: true,
|
||||||
|
precioUnitario: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { codigo: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
},
|
},
|
||||||
gastos: {
|
gastos: {
|
||||||
|
|||||||
104
src/app/api/apu/[id]/duplicate/route.ts
Normal file
104
src/app/api/apu/[id]/duplicate/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
318
src/app/api/apu/[id]/route.ts
Normal file
318
src/app/api/apu/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/api/apu/configuracion/route.ts
Normal file
72
src/app/api/apu/configuracion/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/app/api/apu/equipos/[id]/route.ts
Normal file
171
src/app/api/apu/equipos/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/app/api/apu/equipos/route.ts
Normal file
92
src/app/api/apu/equipos/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/app/api/apu/mano-obra/[id]/route.ts
Normal file
174
src/app/api/apu/mano-obra/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/apu/mano-obra/route.ts
Normal file
94
src/app/api/apu/mano-obra/route.ts
Normal 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
219
src/app/api/apu/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/app/api/presupuestos/[id]/explosion/route.ts
Normal file
184
src/app/api/presupuestos/[id]/explosion/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/app/api/presupuestos/[id]/partidas/[partidaId]/route.ts
Normal file
228
src/app/api/presupuestos/[id]/partidas/[partidaId]/route.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/app/api/presupuestos/[id]/partidas/route.ts
Normal file
186
src/app/api/presupuestos/[id]/partidas/route.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/app/api/presupuestos/route.ts
Normal file
99
src/app/api/presupuestos/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
690
src/components/apu/apu-detail.tsx
Normal file
690
src/components/apu/apu-detail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
734
src/components/apu/apu-form.tsx
Normal file
734
src/components/apu/apu-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
413
src/components/apu/apu-list.tsx
Normal file
413
src/components/apu/apu-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/components/apu/configuracion-apu-form.tsx
Normal file
202
src/components/apu/configuracion-apu-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
src/components/apu/equipo-form.tsx
Normal file
377
src/components/apu/equipo-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/apu/index.ts
Normal file
7
src/components/apu/index.ts
Normal 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";
|
||||||
317
src/components/apu/mano-obra-form.tsx
Normal file
317
src/components/apu/mano-obra-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/components/apu/vincular-apu-dialog.tsx
Normal file
187
src/components/apu/vincular-apu-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Calculator,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -29,6 +30,11 @@ const navigation = [
|
|||||||
href: "/obras",
|
href: "/obras",
|
||||||
icon: HardHat,
|
icon: HardHat,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "APU",
|
||||||
|
href: "/apu",
|
||||||
|
icon: Calculator,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Finanzas",
|
name: "Finanzas",
|
||||||
href: "/finanzas",
|
href: "/finanzas",
|
||||||
|
|||||||
300
src/components/pdf/apu-pdf.tsx
Normal file
300
src/components/pdf/apu-pdf.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ export { ReporteObraPDF } from "./reporte-obra-pdf";
|
|||||||
export { PresupuestoPDF } from "./presupuesto-pdf";
|
export { PresupuestoPDF } from "./presupuesto-pdf";
|
||||||
export { GastosPDF } from "./gastos-pdf";
|
export { GastosPDF } from "./gastos-pdf";
|
||||||
export { BitacoraPDF } from "./bitacora-pdf";
|
export { BitacoraPDF } from "./bitacora-pdf";
|
||||||
|
export { APUPDF } from "./apu-pdf";
|
||||||
export { ExportPDFButton, ExportPDFMenu } from "./export-pdf-button";
|
export { ExportPDFButton, ExportPDFMenu } from "./export-pdf-button";
|
||||||
export * from "./styles";
|
export * from "./styles";
|
||||||
|
|||||||
457
src/components/presupuesto/explosion-insumos.tsx
Normal file
457
src/components/presupuesto/explosion-insumos.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/components/presupuesto/index.ts
Normal file
2
src/components/presupuesto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { PartidasManager } from "./partidas-manager";
|
||||||
|
export { ExplosionInsumos } from "./explosion-insumos";
|
||||||
586
src/components/presupuesto/partidas-manager.tsx
Normal file
586
src/components/presupuesto/partidas-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
||||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal 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 }
|
||||||
@@ -86,6 +86,11 @@ export const partidaPresupuestoSchema = z.object({
|
|||||||
"PIEZA",
|
"PIEZA",
|
||||||
"ROLLO",
|
"ROLLO",
|
||||||
"CAJA",
|
"CAJA",
|
||||||
|
"HORA",
|
||||||
|
"JORNADA",
|
||||||
|
"VIAJE",
|
||||||
|
"LOTE",
|
||||||
|
"GLOBAL",
|
||||||
]),
|
]),
|
||||||
cantidad: z.number().positive("La cantidad debe ser mayor a 0"),
|
cantidad: z.number().positive("La cantidad debe ser mayor a 0"),
|
||||||
precioUnitario: z.number().positive("El precio 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",
|
"PIEZA",
|
||||||
"ROLLO",
|
"ROLLO",
|
||||||
"CAJA",
|
"CAJA",
|
||||||
|
"HORA",
|
||||||
|
"JORNADA",
|
||||||
|
"VIAJE",
|
||||||
|
"LOTE",
|
||||||
|
"GLOBAL",
|
||||||
]),
|
]),
|
||||||
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
|
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
|
||||||
stockMinimo: z.number().min(0, "El stock minimo no puede ser negativo"),
|
stockMinimo: z.number().min(0, "El stock minimo no puede ser negativo"),
|
||||||
|
|||||||
259
src/lib/validations/apu.ts
Normal file
259
src/lib/validations/apu.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
PrioridadOrden,
|
PrioridadOrden,
|
||||||
TipoNotificacion,
|
TipoNotificacion,
|
||||||
TipoActividad,
|
TipoActividad,
|
||||||
|
CategoriaManoObra,
|
||||||
|
TipoEquipo,
|
||||||
|
TipoInsumoAPU,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -32,6 +35,9 @@ export type {
|
|||||||
PrioridadOrden,
|
PrioridadOrden,
|
||||||
TipoNotificacion,
|
TipoNotificacion,
|
||||||
TipoActividad,
|
TipoActividad,
|
||||||
|
CategoriaManoObra,
|
||||||
|
TipoEquipo,
|
||||||
|
TipoInsumoAPU,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
@@ -148,6 +154,11 @@ export const UNIDAD_MEDIDA_LABELS: Record<UnidadMedida, string> = {
|
|||||||
PIEZA: "Pieza",
|
PIEZA: "Pieza",
|
||||||
ROLLO: "Rollo",
|
ROLLO: "Rollo",
|
||||||
CAJA: "Caja",
|
CAJA: "Caja",
|
||||||
|
HORA: "Hora",
|
||||||
|
JORNADA: "Jornada",
|
||||||
|
VIAJE: "Viaje",
|
||||||
|
LOTE: "Lote",
|
||||||
|
GLOBAL: "Global",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ESTADO_FACTURA_LABELS: Record<EstadoFactura, string> = {
|
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",
|
ALTA: "bg-orange-100 text-orange-800",
|
||||||
URGENTE: "bg-red-100 text-red-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",
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user