From 75de172900b709c48c0222a7ef97105d85cf7126 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Sat, 30 May 2026 12:12:07 +0200 Subject: [PATCH] docs: add motor de presupuesto implementation plan Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-30-motor-presupuesto.md | 1610 +++++++++++++++++ 1 file changed, 1610 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-motor-presupuesto.md diff --git a/docs/superpowers/plans/2026-05-30-motor-presupuesto.md b/docs/superpowers/plans/2026-05-30-motor-presupuesto.md new file mode 100644 index 0000000..80f1d74 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-motor-presupuesto.md @@ -0,0 +1,1610 @@ +# Motor de Presupuesto Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Construir el motor de presupuesto de Reformix: una función pura que produce un desglose por partidas a partir de precios configurables + medidas mínimas, con panel para editar la tabla de precios (CRUD + import CSV), seed de catálogo demo, y el punto de integración que el funnel llamará para recalcular y guardar el presupuesto del lead. + +**Architecture:** Núcleo puro en `src/budget/` (sin DB ni red, cubierto por Vitest ≥70%) que recibe `(inputs, config, catalog)` y devuelve `BudgetResult`. La capa Drizzle persiste `pricing_config` + `catalog_items` por tenant y nuevos campos de inputs/snapshot en `leads`. El panel `/panel/precios` hace CRUD sobre config/catálogo. Una Server Action `recalcularPresupuesto(leadId)` une las piezas: lee inputs del lead, llama al motor, guarda total + snapshot. + +**Tech Stack:** Next.js 16 (App Router, Server Actions), TypeScript strict, Drizzle ORM + postgres.js, Tailwind v4, Vitest + @vitest/coverage-v8, zod. Todo dentro de `mvp/b2c`. Tenant único `reformas-ejemplo`. + +**Convención de dinero:** todo en **céntimos** (enteros), igual que el schema actual. + +**Comandos:** ejecutar siempre desde `mvp/b2c`. Tests: `npm run test`. Cobertura: `npm run test:coverage`. + +--- + +## Estructura de ficheros + +**Crear:** +- `mvp/b2c/vitest.config.ts` — config de Vitest (alias `@`, cobertura sobre `src/budget/**`). +- `mvp/b2c/src/budget/types.ts` — tipos del dominio (sin lógica). +- `mvp/b2c/src/budget/labels.ts` — orden y etiquetas de partidas. +- `mvp/b2c/src/budget/derive.ts` — `deriveCantidades()` (medidas → cantidades). +- `mvp/b2c/src/budget/resolve.ts` — `resolvePrecioUnitario()` (lookup en catálogo). +- `mvp/b2c/src/budget/compute.ts` — `computeBudget()` (orquesta todo). +- `mvp/b2c/src/budget/csv.ts` — `parseCatalogCsv()` (parser + validación zod). +- `mvp/b2c/src/budget/index.ts` — re-exports públicos. +- `mvp/b2c/tests/budget/derive.test.ts` +- `mvp/b2c/tests/budget/resolve.test.ts` +- `mvp/b2c/tests/budget/compute.test.ts` +- `mvp/b2c/tests/budget/csv.test.ts` +- `mvp/b2c/src/db/pricing-queries.ts` — `getPricingConfig()`, `getCatalog()` (mapean fila DB → tipos del motor). +- `mvp/b2c/src/app/panel/precios/page.tsx` — UI del panel de precios. +- `mvp/b2c/src/app/panel/precios/actions.ts` — Server Actions CRUD + CSV. + +**Modificar:** +- `mvp/b2c/package.json` — deps (`zod`) + devDeps (`vitest`, `@vitest/coverage-v8`) + scripts `test`, `test:coverage`. +- `mvp/b2c/src/db/schema.ts` — enums + tablas `pricing_config`, `catalog_items` + campos nuevos en `leads`. +- `mvp/b2c/src/db/seed.ts` — sembrar `pricing_config` + catálogo demo + inputs demo en un lead. +- `mvp/b2c/src/app/panel/actions.ts` — añadir `recalcularPresupuesto(leadId)`. +- `mvp/b2c/src/app/panel/[id]/page.tsx` — mostrar el desglose del presupuesto. +- `mvp/b2c/src/app/panel/layout.tsx` — enlace de navegación a `/panel/precios`. + +--- + +## Task 1: Montar Vitest + +**Files:** +- Modify: `mvp/b2c/package.json` +- Create: `mvp/b2c/vitest.config.ts` +- Create: `mvp/b2c/tests/smoke.test.ts` + +- [ ] **Step 1: Instalar dependencias de test** + +Run (desde `mvp/b2c`): +```bash +npm install -D vitest @vitest/coverage-v8 +npm install zod +``` +Expected: `package.json` actualizado, sin errores. + +- [ ] **Step 2: Añadir scripts a package.json** + +En `mvp/b2c/package.json`, dentro de `"scripts"`, añadir tras `"db:seed"`: +```json + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" +``` + +- [ ] **Step 3: Crear vitest.config.ts** + +```ts +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/budget/**'], + thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 }, + }, + }, +}); +``` + +- [ ] **Step 4: Crear un test de humo** + +`mvp/b2c/tests/smoke.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; + +describe('vitest setup', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 5: Ejecutar y verificar que pasa** + +Run: `npm run test` +Expected: 1 passed. + +- [ ] **Step 6: Commit** + +```bash +git add mvp/b2c/package.json mvp/b2c/package-lock.json mvp/b2c/vitest.config.ts mvp/b2c/tests/smoke.test.ts +git commit -m "chore: set up vitest and add zod" +``` + +--- + +## Task 2: Tipos del dominio y etiquetas de partidas + +**Files:** +- Create: `mvp/b2c/src/budget/types.ts` +- Create: `mvp/b2c/src/budget/labels.ts` + +Sin tests propios (son solo tipos/constantes; se ejercitan en Tasks 3-5). + +- [ ] **Step 1: Crear types.ts** + +```ts +export type Calidad = 'basica' | 'media' | 'premium'; +export type Unidad = 'm2' | 'ml' | 'ud'; +export type CategoriaMaterial = 'suelo' | 'pared' | 'pintura' | 'mobiliario'; +export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' | 'otro'; + +export type PartidaKey = + | 'demolicion' + | 'alicatado' + | 'fontaneria' + | 'electricidad' + | 'carpinteria' + | 'mano_de_obra' + | 'extras' + | 'licencia'; + +export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra'; + +export interface CatalogItem { + id: string; + categoria: CategoriaMaterial; + nombre: string; + calidad: Calidad; + precioUnit: number; // céntimos por unidad + unidad: Unidad; + descriptorRender: string; + esDefault: boolean; + sku: string; +} + +export interface PricingConfig { + alturaTechoDefault: number; // metros + factorZona: Record; // provincia -> multiplicador + manoObra: Record; // céntimos por m² de suelo +} + +export interface BudgetInputs { + tipoReforma: TipoReforma; + m2Suelo: number | null; + alturaTecho: number | null; + calidadGlobal: Calidad; + estructural: boolean; + provincia: string | null; + materialSelections: Partial>; // categoria -> catalogItemId +} + +export interface PartidaResult { + key: PartidaKey; + label: string; + importe: number; // céntimos (base, antes de factor zona) +} + +export interface BudgetResult { + partidas: PartidaResult[]; + subtotal: number; // céntimos + factorZona: number; + total: number; // céntimos = round(subtotal * factorZona) + rango: { min: number; max: number }; // céntimos + confianza: 'baja' | 'media' | 'alta'; + materialesRender: string[]; // descriptores para el prompt del render + avisos: string[]; +} +``` + +- [ ] **Step 2: Crear labels.ts** + +```ts +import type { PartidaKey } from './types'; + +export const PARTIDA_ORDER: PartidaKey[] = [ + 'demolicion', + 'alicatado', + 'fontaneria', + 'electricidad', + 'carpinteria', + 'mano_de_obra', + 'extras', + 'licencia', +]; + +export const PARTIDA_LABEL: Record = { + demolicion: 'Demolición', + alicatado: 'Alicatado y solado', + fontaneria: 'Fontanería', + electricidad: 'Electricidad', + carpinteria: 'Carpintería y mobiliario', + mano_de_obra: 'Mano de obra', + extras: 'Pintura y extras', + licencia: 'Licencia + Proyecto técnico', +}; +``` + +- [ ] **Step 3: Commit** + +```bash +git add mvp/b2c/src/budget/types.ts mvp/b2c/src/budget/labels.ts +git commit -m "feat: add budget domain types and partida labels" +``` + +--- + +## Task 3: Derivar cantidades desde las medidas + +**Files:** +- Create: `mvp/b2c/src/budget/derive.ts` +- Test: `mvp/b2c/tests/budget/derive.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/derive.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { deriveCantidades } from '@/budget/derive'; +import type { BudgetInputs, PricingConfig } from '@/budget/types'; + +const config: PricingConfig = { + alturaTechoDefault: 2.5, + factorZona: {}, + manoObra: { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 }, +}; + +function inputs(partial: Partial): BudgetInputs { + return { + tipoReforma: 'cocina', + m2Suelo: null, + alturaTecho: null, + calidadGlobal: 'media', + estructural: false, + provincia: null, + materialSelections: {}, + ...partial, + }; +} + +describe('deriveCantidades', () => { + it('usa m² aportados y deriva perímetro y paredes con números limpios', () => { + // m2Suelo=16 -> sqrt=4 -> perimetro=16 -> pared=16*2.5=40 -> mobiliario=16*0.5=8 (cocina) + const c = deriveCantidades(inputs({ m2Suelo: 16 }), config); + expect(c.m2Suelo).toBe(16); + expect(c.perimetro).toBe(16); + expect(c.m2Pared).toBe(40); + expect(c.mlMobiliario).toBe(8); + expect(c.alturaTecho).toBe(2.5); + }); + + it('cae a la mediana por tipo cuando no hay m²', () => { + const c = deriveCantidades(inputs({ tipoReforma: 'bano', m2Suelo: null }), config); + expect(c.m2Suelo).toBe(5); // mediana baño + }); + + it('usa la altura aportada por encima del default', () => { + const c = deriveCantidades(inputs({ m2Suelo: 16, alturaTecho: 3 }), config); + expect(c.alturaTecho).toBe(3); + expect(c.m2Pared).toBe(48); // 16 * 3 + }); + + it('no calcula mobiliario para tipos sin cocina/baño', () => { + const c = deriveCantidades(inputs({ tipoReforma: 'salon', m2Suelo: 16 }), config); + expect(c.mlMobiliario).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- derive` +Expected: FAIL ("Cannot find module '@/budget/derive'" o similar). + +- [ ] **Step 3: Implementar derive.ts** + +```ts +import type { BudgetInputs, PricingConfig, TipoReforma } from './types'; + +export interface Cantidades { + m2Suelo: number; + m2Pared: number; + mlMobiliario: number; + perimetro: number; + alturaTecho: number; +} + +const M2_MEDIANA: Record = { + cocina: 10, + bano: 5, + salon: 20, + comedor: 16, + integral: 70, + otro: 12, +}; + +// Metros lineales de mobiliario por metro de perímetro. Solo cocina/baño. +const FACTOR_MOBILIARIO: Partial> = { + cocina: 0.5, + bano: 0.3, +}; + +export function deriveCantidades(inputs: BudgetInputs, config: PricingConfig): Cantidades { + const m2Suelo = + inputs.m2Suelo != null && inputs.m2Suelo > 0 + ? inputs.m2Suelo + : M2_MEDIANA[inputs.tipoReforma]; + const alturaTecho = + inputs.alturaTecho != null && inputs.alturaTecho > 0 + ? inputs.alturaTecho + : config.alturaTechoDefault; + const perimetro = 4 * Math.sqrt(m2Suelo); + const m2Pared = perimetro * alturaTecho; + const mlMobiliario = perimetro * (FACTOR_MOBILIARIO[inputs.tipoReforma] ?? 0); + return { m2Suelo, m2Pared, mlMobiliario, perimetro, alturaTecho }; +} +``` + +- [ ] **Step 4: Ejecutar para verificar que pasa** + +Run: `npm run test -- derive` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add mvp/b2c/src/budget/derive.ts mvp/b2c/tests/budget/derive.test.ts +git commit -m "feat: derive cantidades from minimal measurements" +``` + +--- + +## Task 4: Resolver el precio unitario desde el catálogo + +**Files:** +- Create: `mvp/b2c/src/budget/resolve.ts` +- Test: `mvp/b2c/tests/budget/resolve.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/resolve.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { resolvePrecioUnitario } from '@/budget/resolve'; +import type { CatalogItem } from '@/budget/types'; + +const catalog: CatalogItem[] = [ + { id: 's-media', categoria: 'suelo', nombre: 'Cerámico medio', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' }, + { id: 's-premium', categoria: 'suelo', nombre: 'Porcelánico roble', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'porcelánico símil roble', esDefault: true, sku: 'SUE-P' }, +]; + +describe('resolvePrecioUnitario', () => { + it('devuelve el default de la calidad cuando no hay selección', () => { + const { item } = resolvePrecioUnitario('suelo', 'media', catalog, {}); + expect(item?.id).toBe('s-media'); + }); + + it('prioriza la selección exacta sobre la calidad global', () => { + const { item } = resolvePrecioUnitario('suelo', 'media', catalog, { suelo: 's-premium' }); + expect(item?.id).toBe('s-premium'); + }); + + it('devuelve null si no hay default para esa calidad ni selección', () => { + const { item } = resolvePrecioUnitario('pared', 'media', catalog, {}); + expect(item).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- resolve` +Expected: FAIL ("Cannot find module '@/budget/resolve'"). + +- [ ] **Step 3: Implementar resolve.ts** + +```ts +import type { Calidad, CategoriaMaterial, CatalogItem } from './types'; + +export function resolvePrecioUnitario( + categoria: CategoriaMaterial, + calidad: Calidad, + catalog: CatalogItem[], + selections: Partial>, +): { item: CatalogItem | null } { + const selectedId = selections[categoria]; + if (selectedId) { + const selected = catalog.find((c) => c.id === selectedId); + if (selected) return { item: selected }; + } + const def = catalog.find( + (c) => c.categoria === categoria && c.calidad === calidad && c.esDefault, + ); + return { item: def ?? null }; +} +``` + +- [ ] **Step 4: Ejecutar para verificar que pasa** + +Run: `npm run test -- resolve` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add mvp/b2c/src/budget/resolve.ts mvp/b2c/tests/budget/resolve.test.ts +git commit -m "feat: resolve unit price from catalog with selection override" +``` + +--- + +## Task 5: computeBudget (núcleo) + +**Files:** +- Create: `mvp/b2c/src/budget/compute.ts` +- Create: `mvp/b2c/src/budget/index.ts` +- Test: `mvp/b2c/tests/budget/compute.test.ts` + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/compute.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { computeBudget } from '@/budget/compute'; +import type { BudgetInputs, CatalogItem, PricingConfig } from '@/budget/types'; + +const config: PricingConfig = { + alturaTechoDefault: 2.5, + factorZona: { Madrid: 1.1 }, + manoObra: { demolicion: 1500, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 }, +}; + +const catalog: CatalogItem[] = [ + { id: 'suelo-m', categoria: 'suelo', nombre: 'Cerámico', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' }, + { id: 'pared-m', categoria: 'pared', nombre: 'Azulejo', calidad: 'media', precioUnit: 2400, unidad: 'm2', descriptorRender: 'azulejo blanco', esDefault: true, sku: 'PAR-M' }, + { id: 'pintura-m', categoria: 'pintura', nombre: 'Plástica', calidad: 'media', precioUnit: 800, unidad: 'm2', descriptorRender: 'pintura blanca mate', esDefault: true, sku: 'PIN-M' }, + { id: 'mob-m', categoria: 'mobiliario', nombre: 'Muebles cocina', calidad: 'media', precioUnit: 32000, unidad: 'ml', descriptorRender: 'muebles laminado roble', esDefault: true, sku: 'MOB-M' }, + { id: 'suelo-p', categoria: 'suelo', nombre: 'Porcelánico', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'porcelánico símil roble', esDefault: true, sku: 'SUE-P' }, +]; + +function inputs(partial: Partial): BudgetInputs { + return { + tipoReforma: 'cocina', + m2Suelo: 16, + alturaTecho: null, + calidadGlobal: 'media', + estructural: false, + provincia: 'Madrid', + materialSelections: {}, + ...partial, + }; +} + +describe('computeBudget', () => { + it('calcula partidas, subtotal, factor zona y total con números conocidos', () => { + const r = computeBudget(inputs({}), config, catalog); + // m2Suelo=16 -> perimetro=16, pared=40, mobiliario=8 + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.demolicion).toBe(24000); // 16*1500 + expect(byKey.alicatado).toBe(140800); // 16*2800 + 40*2400 + expect(byKey.fontaneria).toBe(19200); // 16*1200 + expect(byKey.electricidad).toBe(16000); // 16*1000 + expect(byKey.carpinteria).toBe(256000); // 8*32000 + expect(byKey.mano_de_obra).toBe(48000); // 16*3000 + expect(byKey.extras).toBe(32000); // 40*800 + expect(byKey.licencia).toBeUndefined(); + expect(r.subtotal).toBe(536000); + expect(r.factorZona).toBe(1.1); + expect(r.total).toBe(589600); // round(536000 * 1.1) + }); + + it('confianza media (±15%) con m² pero sin selección exacta', () => { + const r = computeBudget(inputs({}), config, catalog); + expect(r.confianza).toBe('media'); + expect(r.rango.min).toBe(501160); // round(589600*0.85) + expect(r.rango.max).toBe(678040); // round(589600*1.15) + }); + + it('confianza alta (±10%) con m² y selección exacta', () => { + const r = computeBudget(inputs({ materialSelections: { suelo: 'suelo-p' } }), config, catalog); + expect(r.confianza).toBe('alta'); + expect(r.materialesRender).toContain('porcelánico símil roble'); + }); + + it('confianza baja (±25%) sin m² ni selección', () => { + const r = computeBudget(inputs({ m2Suelo: null }), config, catalog); + expect(r.confianza).toBe('baja'); + }); + + it('añade partida de licencia y amplía el máximo si hay cambio estructural', () => { + const base = computeBudget(inputs({}), config, catalog); + const r = computeBudget(inputs({ estructural: true }), config, catalog); + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.licencia).toBe(30000); // 300€ mínimo + expect(r.rango.max).toBe(base.rango.max + 33000 + 120000); + // +33000 = round((536000+30000)*1.1)-589600 efecto licencia en total; +120000 = banda licencia + }); + + it('emite aviso cuando falta precio de una categoría', () => { + const sinPintura = catalog.filter((c) => c.categoria !== 'pintura'); + const r = computeBudget(inputs({}), config, sinPintura); + expect(r.avisos.some((a) => a.includes('pintura'))).toBe(true); + }); +}); +``` + +> Nota sobre el test de licencia: con estructural el subtotal pasa a 566000, total = round(566000*1.1) = 622600 (= base.total + 33000). La banda media (±15%) da max = round(622600*1.15) = 715990, y se le suma la banda de licencia (120000). El test lo expresa como `base.rango.max (678040) + 33000 + 120000 = 831040`. Verifica que el cálculo del implementador cuadra; si la implementación de abajo da otro número exacto por redondeo, ajusta el valor esperado al de la implementación de referencia (no cambies la fórmula). + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- compute` +Expected: FAIL ("Cannot find module '@/budget/compute'"). + +- [ ] **Step 3: Implementar compute.ts** + +```ts +import { deriveCantidades } from './derive'; +import { resolvePrecioUnitario } from './resolve'; +import { PARTIDA_LABEL, PARTIDA_ORDER } from './labels'; +import type { + BudgetInputs, + BudgetResult, + CategoriaMaterial, + CatalogItem, + PartidaKey, + PricingConfig, +} from './types'; + +const LICENCIA_MIN = 30000; // 300 € +const LICENCIA_MAX = 150000; // 1.500 € + +// A qué partida contribuye el material de cada categoría. +const MATERIAL_PARTIDA: Record = { + suelo: 'alicatado', + pared: 'alicatado', + pintura: 'extras', + mobiliario: 'carpinteria', +}; + +const CATEGORIAS: CategoriaMaterial[] = ['suelo', 'pared', 'pintura', 'mobiliario']; + +export function computeBudget( + inputs: BudgetInputs, + config: PricingConfig, + catalog: CatalogItem[], +): BudgetResult { + const cant = deriveCantidades(inputs, config); + const avisos: string[] = []; + const materialesRender: string[] = []; + + const importes: Record = { + demolicion: 0, + alicatado: 0, + fontaneria: 0, + electricidad: 0, + carpinteria: 0, + mano_de_obra: 0, + extras: 0, + licencia: 0, + }; + + const cantidadPorCategoria: Record = { + suelo: cant.m2Suelo, + pared: cant.m2Pared, + pintura: cant.m2Pared, + mobiliario: cant.mlMobiliario, + }; + + for (const categoria of CATEGORIAS) { + const cantidad = cantidadPorCategoria[categoria]; + if (cantidad <= 0) continue; + const { item } = resolvePrecioUnitario( + categoria, + inputs.calidadGlobal, + catalog, + inputs.materialSelections, + ); + if (!item) { + avisos.push(`Sin precio para ${categoria} (calidad ${inputs.calidadGlobal})`); + continue; + } + importes[MATERIAL_PARTIDA[categoria]] += Math.round(cantidad * item.precioUnit); + if (item.descriptorRender) materialesRender.push(item.descriptorRender); + } + + importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion); + importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria); + importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad); + importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra); + + if (inputs.estructural) importes.licencia += LICENCIA_MIN; + + const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({ + key: k, + label: PARTIDA_LABEL[k], + importe: importes[k], + })); + + const subtotal = partidas.reduce((s, p) => s + p.importe, 0); + const factorZona = (inputs.provincia && config.factorZona[inputs.provincia]) || 1; + const total = Math.round(subtotal * factorZona); + + const hasExact = Object.keys(inputs.materialSelections).length > 0; + const hasM2 = inputs.m2Suelo != null && inputs.m2Suelo > 0; + let confianza: BudgetResult['confianza']; + let band: number; + if (hasM2 && hasExact) { + confianza = 'alta'; + band = 0.1; + } else if (hasM2 || hasExact) { + confianza = 'media'; + band = 0.15; + } else { + confianza = 'baja'; + band = 0.25; + } + + const rango = { + min: Math.round(total * (1 - band)), + max: Math.round(total * (1 + band)) + (inputs.estructural ? LICENCIA_MAX - LICENCIA_MIN : 0), + }; + + return { partidas, subtotal, factorZona, total, rango, confianza, materialesRender, avisos }; +} +``` + +- [ ] **Step 4: Crear index.ts (re-exports)** + +```ts +export * from './types'; +export { PARTIDA_LABEL, PARTIDA_ORDER } from './labels'; +export { deriveCantidades } from './derive'; +export { resolvePrecioUnitario } from './resolve'; +export { computeBudget } from './compute'; +export { parseCatalogCsv } from './csv'; +``` + +> `./csv` se crea en la Task 6. Si ejecutas esta task de forma aislada, comenta esa línea hasta completar la Task 6. + +- [ ] **Step 5: Ejecutar para verificar que pasa** + +Run: `npm run test -- compute` +Expected: PASS. Si el test de licencia falla por el valor esperado exacto, ajusta SOLO ese número al que produce esta implementación (ver nota del Step 1). + +- [ ] **Step 6: Commit** + +```bash +git add mvp/b2c/src/budget/compute.ts mvp/b2c/src/budget/index.ts mvp/b2c/tests/budget/compute.test.ts +git commit -m "feat: implement computeBudget with partidas, zona factor, licencia and range" +``` + +--- + +## Task 6: Parser de catálogo CSV + +**Files:** +- Create: `mvp/b2c/src/budget/csv.ts` +- Test: `mvp/b2c/tests/budget/csv.test.ts` + +Formato CSV (primera línea = cabecera): `categoria,nombre,calidad,precio,unidad,descriptor_render,sku` +`precio` viene en **euros** (decimal con punto) y se convierte a céntimos. + +- [ ] **Step 1: Escribir el test que falla** + +`mvp/b2c/tests/budget/csv.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { parseCatalogCsv } from '@/budget/csv'; + +const HEADER = 'categoria,nombre,calidad,precio,unidad,descriptor_render,sku'; + +describe('parseCatalogCsv', () => { + it('parsea filas válidas y convierte precio a céntimos', () => { + const csv = [ + HEADER, + 'suelo,Cerámico gris,media,28.00,m2,suelo cerámico gris,SUE-M', + 'mobiliario,Muebles cocina,premium,550,ml,muebles laminado roble,MOB-P', + ].join('\n'); + const { rows, errors } = parseCatalogCsv(csv); + expect(errors).toHaveLength(0); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + categoria: 'suelo', + calidad: 'media', + precioUnit: 2800, + unidad: 'm2', + sku: 'SUE-M', + }); + expect(rows[1].precioUnit).toBe(55000); + }); + + it('reporta errores por fila sin abortar las válidas', () => { + const csv = [ + HEADER, + 'suelo,Bueno,media,28,m2,desc,SUE-M', + 'inventada,Malo,media,10,m2,desc,X', // categoria inválida + 'pared,Sin precio,media,abc,m2,desc,PAR-M', // precio no numérico + ].join('\n'); + const { rows, errors } = parseCatalogCsv(csv); + expect(rows).toHaveLength(1); + expect(errors).toHaveLength(2); + expect(errors[0].line).toBe(3); // 1-indexed incluyendo cabecera + expect(errors[1].line).toBe(4); + }); + + it('devuelve error global si falta la cabecera esperada', () => { + const { rows, errors } = parseCatalogCsv('a,b,c\n1,2,3'); + expect(rows).toHaveLength(0); + expect(errors[0].message).toMatch(/cabecera/i); + }); +}); +``` + +- [ ] **Step 2: Ejecutar para verificar que falla** + +Run: `npm run test -- csv` +Expected: FAIL ("Cannot find module '@/budget/csv'"). + +- [ ] **Step 3: Implementar csv.ts** + +```ts +import { z } from 'zod'; +import type { CategoriaMaterial, Calidad, Unidad } from './types'; + +export interface ParsedCatalogRow { + categoria: CategoriaMaterial; + nombre: string; + calidad: Calidad; + precioUnit: number; // céntimos + unidad: Unidad; + descriptorRender: string; + sku: string; +} + +export interface CsvError { + line: number; // 1-indexed (la cabecera es la línea 1) + message: string; +} + +const HEADER = ['categoria', 'nombre', 'calidad', 'precio', 'unidad', 'descriptor_render', 'sku']; + +const rowSchema = z.object({ + categoria: z.enum(['suelo', 'pared', 'pintura', 'mobiliario']), + nombre: z.string().min(1), + calidad: z.enum(['basica', 'media', 'premium']), + precio: z + .string() + .transform((s) => Number(s)) + .refine((n) => Number.isFinite(n) && n > 0, 'precio inválido'), + unidad: z.enum(['m2', 'ml', 'ud']), + descriptor_render: z.string(), + sku: z.string().min(1), +}); + +export function parseCatalogCsv(text: string): { rows: ParsedCatalogRow[]; errors: CsvError[] } { + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + if (lines.length === 0) { + return { rows: [], errors: [{ line: 1, message: 'CSV vacío' }] }; + } + + const header = lines[0].split(',').map((h) => h.trim()); + if (HEADER.some((h, i) => header[i] !== h)) { + return { + rows: [], + errors: [{ line: 1, message: `Cabecera inválida. Esperada: ${HEADER.join(',')}` }], + }; + } + + const rows: ParsedCatalogRow[] = []; + const errors: CsvError[] = []; + + for (let i = 1; i < lines.length; i++) { + const cells = lines[i].split(',').map((c) => c.trim()); + const record = Object.fromEntries(HEADER.map((h, idx) => [h, cells[idx] ?? ''])); + const parsed = rowSchema.safeParse(record); + if (!parsed.success) { + errors.push({ line: i + 1, message: parsed.error.issues[0]?.message ?? 'fila inválida' }); + continue; + } + const d = parsed.data; + rows.push({ + categoria: d.categoria, + nombre: d.nombre, + calidad: d.calidad, + precioUnit: Math.round(d.precio * 100), + unidad: d.unidad, + descriptorRender: d.descriptor_render, + sku: d.sku, + }); + } + + return { rows, errors }; +} +``` + +- [ ] **Step 4: Ejecutar para verificar que pasa** + +Run: `npm run test -- csv` +Expected: PASS (3 tests). + +- [ ] **Step 5: Verificar cobertura del motor** + +Run: `npm run test:coverage` +Expected: PASS y cobertura de `src/budget/**` ≥ 70% en lines/functions/statements/branches. + +- [ ] **Step 6: Commit** + +```bash +git add mvp/b2c/src/budget/csv.ts mvp/b2c/tests/budget/csv.test.ts +git commit -m "feat: add catalog CSV parser with per-row validation" +``` + +--- + +## Task 7: Extender el schema Drizzle + +**Files:** +- Modify: `mvp/b2c/src/db/schema.ts` + +Sin test unitario (es definición de schema; se valida con la migración en Task 8). + +- [ ] **Step 1: Añadir imports faltantes** + +En `mvp/b2c/src/db/schema.ts`, en el bloque de import de `drizzle-orm/pg-core`, añadir `doublePrecision` y `uniqueIndex` a la lista existente: + +```ts +import { + pgTable, + pgEnum, + uuid, + text, + integer, + boolean, + numeric, + timestamp, + jsonb, + index, + doublePrecision, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +``` + +- [ ] **Step 2: Añadir los enums nuevos** + +Tras el bloque del enum `tipoReforma` (antes de la tabla `tenants`), añadir: + +```ts +export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']); + +export const categoriaMaterial = pgEnum('categoria_material', [ + 'suelo', + 'pared', + 'pintura', + 'mobiliario', +]); + +export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']); +``` + +- [ ] **Step 3: Añadir campos nuevos a la tabla `leads`** + +Dentro del objeto de columnas de `leads`, tras la línea `notas: text('notas'),`, añadir (antes del cierre `}` del objeto de columnas): + +```ts + // Inputs del motor de presupuesto (capturados de menos a más en el funnel) + m2Suelo: doublePrecision('m2_suelo'), + alturaTecho: doublePrecision('altura_techo'), + calidadGlobal: calidad('calidad_global'), + estructural: boolean('estructural').notNull().default(false), + materialSelections: jsonb('material_selections') + .$type>() + .notNull() + .default({}), + desgloseSnapshot: jsonb('desglose_snapshot'), +``` + +- [ ] **Step 4: Añadir las tablas pricing_config y catalog_items** + +Tras la definición de `precisionHistory` (antes del bloque de `export type`), añadir: + +```ts +// Configuración de precios del reformista (1 fila por tenant). RF-D-07. +export const pricingConfig = pgTable('pricing_config', { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: uuid('tenant_id') + .notNull() + .references(() => tenants.id, { onDelete: 'cascade' }) + .unique(), + alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5), + factorZona: jsonb('factor_zona').$type>().notNull().default({}), + manoObra: jsonb('mano_obra').$type>().notNull().default({}), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +// Catálogo de materiales del reformista. Importable por CSV. +export const catalogItems = pgTable( + 'catalog_items', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: uuid('tenant_id') + .notNull() + .references(() => tenants.id, { onDelete: 'cascade' }), + categoria: categoriaMaterial('categoria').notNull(), + nombre: text('nombre').notNull(), + calidad: calidad('calidad').notNull(), + precioUnit: integer('precio_unit').notNull(), // céntimos por unidad + unidad: unidadMedida('unidad').notNull(), + descriptorRender: text('descriptor_render').notNull().default(''), + esDefault: boolean('es_default').notNull().default(false), + sku: text('sku').notNull(), + }, + (table) => [ + index('catalog_tenant_idx').on(table.tenantId), + uniqueIndex('catalog_tenant_sku_idx').on(table.tenantId, table.sku), + ] +); +``` + +- [ ] **Step 5: Añadir los tipos inferidos** + +Al final del fichero, tras `export type PrecisionHistory = ...`, añadir: + +```ts +export type PricingConfigRow = typeof pricingConfig.$inferSelect; +export type CatalogItemRow = typeof catalogItems.$inferSelect; +export type NewCatalogItem = typeof catalogItems.$inferInsert; +``` + +- [ ] **Step 6: Verificar que compila el typecheck del schema** + +Run: `npx tsc --noEmit` +Expected: sin errores en `src/db/schema.ts` (puede haber errores preexistentes en otros ficheros; ignóralos si no son del schema). + +- [ ] **Step 7: Commit** + +```bash +git add mvp/b2c/src/db/schema.ts +git commit -m "feat: add pricing_config, catalog_items and budget input fields to schema" +``` + +--- + +## Task 8: Generar migración y sembrar config + catálogo demo + +**Files:** +- Create: `mvp/b2c/drizzle/0001_*.sql` (lo genera drizzle-kit) +- Modify: `mvp/b2c/src/db/seed.ts` + +Requiere `DATABASE_URL` en `mvp/b2c/.env.local` apuntando a un Postgres accesible. + +- [ ] **Step 1: Generar la migración** + +Run (desde `mvp/b2c`): `npm run db:generate` +Expected: nuevo fichero en `drizzle/0001_*.sql` con `CREATE TABLE pricing_config`, `CREATE TABLE catalog_items`, los nuevos `CREATE TYPE` y los `ALTER TABLE leads ADD COLUMN`. + +- [ ] **Step 2: Añadir el sembrado de precios y catálogo al seed** + +En `mvp/b2c/src/db/seed.ts`, justo antes de `await client.end();` (al final de `main()`), añadir este bloque autocontenido: + +```ts + // --- Precios + catálogo demo (motor de presupuesto) --- + const [tenantRow] = await db + .select() + .from(schema.tenants) + .where(eq(schema.tenants.slug, 'reformas-ejemplo')) + .limit(1); + + if (tenantRow) { + await db.delete(schema.catalogItems).where(eq(schema.catalogItems.tenantId, tenantRow.id)); + await db.delete(schema.pricingConfig).where(eq(schema.pricingConfig.tenantId, tenantRow.id)); + + await db.insert(schema.pricingConfig).values({ + tenantId: tenantRow.id, + alturaTechoDefault: 2.5, + factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 }, + manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 }, + }); + + const cat = ( + categoria: 'suelo' | 'pared' | 'pintura' | 'mobiliario', + nombre: string, + calidad: 'basica' | 'media' | 'premium', + precioEuros: number, + unidad: 'm2' | 'ml' | 'ud', + descriptorRender: string, + sku: string, + ) => ({ + tenantId: tenantRow.id, + categoria, + nombre, + calidad, + precioUnit: Math.round(precioEuros * 100), + unidad, + descriptorRender, + esDefault: true, + sku, + }); + + await db.insert(schema.catalogItems).values([ + cat('suelo', 'Gres cerámico básico', 'basica', 16, 'm2', 'suelo gres beige liso', 'SUE-B'), + cat('suelo', 'Porcelánico símil madera', 'media', 28, 'm2', 'porcelánico símil roble claro', 'SUE-M'), + cat('suelo', 'Porcelánico gran formato', 'premium', 48, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'), + cat('pared', 'Azulejo blanco brillo', 'basica', 14, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'), + cat('pared', 'Azulejo rectificado', 'media', 24, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'), + cat('pared', 'Porcelánico decorativo', 'premium', 42, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'), + cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'), + cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'), + cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'), + cat('mobiliario', 'Muebles melamina', 'basica', 180, 'ml', 'muebles cocina melamina blanca', 'MOB-B'), + cat('mobiliario', 'Muebles laminado', 'media', 320, 'ml', 'muebles cocina laminado roble con tirador integrado', 'MOB-M'), + cat('mobiliario', 'Muebles lacado', 'premium', 550, 'ml', 'muebles cocina lacado mate antracita y encimera porcelánica', 'MOB-P'), + ]); + + // Inputs demo en un lead ya avanzado para poder recalcular su presupuesto. + await db + .update(schema.leads) + .set({ m2Suelo: 12, calidadGlobal: 'media', estructural: false }) + .where(eq(schema.leads.email, 'roberto.salas@example.com')); + } +``` + +> Nota: el bloque es idempotente por sí mismo (borra config+catálogo del tenant y reinserta). Usa `schema.catalogItems`, `schema.pricingConfig`, `schema.leads` ya importados vía `import * as schema`. `eq` ya está importado en seed.ts. + +- [ ] **Step 3: Aplicar migración y seed** + +Run (desde `mvp/b2c`): +```bash +npm run db:migrate +SEED_FORCE=1 npm run db:seed +``` +Expected: migración aplicada; seed imprime su log sin errores. + +- [ ] **Step 4: Verificar datos** + +Run (desde `mvp/b2c`): +```bash +node -e "const p=require('postgres')(process.env.DATABASE_URL,{prepare:false});p\`select count(*) from catalog_items\`.then(r=>{console.log(r);return p.end()})" +``` +Expected: count = 12. (Si prefieres, usa `npm run db:studio` para inspeccionar visualmente.) + +- [ ] **Step 5: Commit** + +```bash +git add mvp/b2c/drizzle mvp/b2c/src/db/seed.ts +git commit -m "feat: migrate and seed pricing config + demo catalog" +``` + +--- + +## Task 9: Queries para mapear DB → tipos del motor + +**Files:** +- Create: `mvp/b2c/src/db/pricing-queries.ts` + +Sin test unitario (capa de acceso a DB; se valida en el panel y en la integración). + +- [ ] **Step 1: Implementar pricing-queries.ts** + +```ts +import { eq } from 'drizzle-orm'; +import { db } from './index'; +import { pricingConfig, catalogItems, tenants } from './schema'; +import { TENANT_SLUG } from '@/lib/funnel'; +import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types'; + +async function getTenantId(): Promise { + const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1); + if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`); + return tenant.id; +} + +const MANO_OBRA_DEFAULT: Record = { + demolicion: 0, + fontaneria: 0, + electricidad: 0, + mano_de_obra: 0, +}; + +export async function getPricingConfig(): Promise { + const tenantId = await getTenantId(); + const [row] = await db + .select() + .from(pricingConfig) + .where(eq(pricingConfig.tenantId, tenantId)) + .limit(1); + + if (!row) { + return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } }; + } + return { + alturaTechoDefault: row.alturaTechoDefault, + factorZona: row.factorZona, + manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record) }, + }; +} + +export async function getCatalog(): Promise { + const tenantId = await getTenantId(); + const rows = await db.select().from(catalogItems).where(eq(catalogItems.tenantId, tenantId)); + return rows.map((r) => ({ + id: r.id, + categoria: r.categoria, + nombre: r.nombre, + calidad: r.calidad, + precioUnit: r.precioUnit, + unidad: r.unidad, + descriptorRender: r.descriptorRender, + esDefault: r.esDefault, + sku: r.sku, + })); +} + +export { getTenantId }; +``` + +- [ ] **Step 2: Verificar typecheck** + +Run: `npx tsc --noEmit` +Expected: sin errores nuevos en `pricing-queries.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add mvp/b2c/src/db/pricing-queries.ts +git commit -m "feat: add queries mapping pricing config and catalog to engine types" +``` + +--- + +## Task 10: Panel de precios (CRUD + import CSV) + +**Files:** +- Create: `mvp/b2c/src/app/panel/precios/actions.ts` +- Create: `mvp/b2c/src/app/panel/precios/page.tsx` +- Modify: `mvp/b2c/src/app/panel/layout.tsx` + +- [ ] **Step 1: Crear las Server Actions** + +`mvp/b2c/src/app/panel/precios/actions.ts`: +```ts +'use server'; + +import { and, eq } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; +import { db } from '@/db'; +import { catalogItems, pricingConfig } from '@/db/schema'; +import { getTenantId } from '@/db/pricing-queries'; +import { parseCatalogCsv } from '@/budget/csv'; + +export async function crearMaterial(formData: FormData) { + const tenantId = await getTenantId(); + await db.insert(catalogItems).values({ + tenantId, + categoria: formData.get('categoria') as 'suelo' | 'pared' | 'pintura' | 'mobiliario', + nombre: String(formData.get('nombre') ?? ''), + calidad: formData.get('calidad') as 'basica' | 'media' | 'premium', + precioUnit: Math.round(Number(formData.get('precioEuros') ?? 0) * 100), + unidad: formData.get('unidad') as 'm2' | 'ml' | 'ud', + descriptorRender: String(formData.get('descriptorRender') ?? ''), + esDefault: formData.get('esDefault') === 'on', + sku: String(formData.get('sku') ?? ''), + }); + revalidatePath('/panel/precios'); +} + +export async function actualizarPrecio(formData: FormData) { + const tenantId = await getTenantId(); + const id = String(formData.get('id') ?? ''); + await db + .update(catalogItems) + .set({ precioUnit: Math.round(Number(formData.get('precioEuros') ?? 0) * 100) }) + .where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId))); + revalidatePath('/panel/precios'); +} + +export async function borrarMaterial(formData: FormData) { + const tenantId = await getTenantId(); + const id = String(formData.get('id') ?? ''); + await db.delete(catalogItems).where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId))); + revalidatePath('/panel/precios'); +} + +export async function actualizarConfig(formData: FormData) { + const tenantId = await getTenantId(); + await db + .update(pricingConfig) + .set({ + alturaTechoDefault: Number(formData.get('alturaTechoDefault') ?? 2.5), + manoObra: { + demolicion: Math.round(Number(formData.get('mo_demolicion') ?? 0) * 100), + fontaneria: Math.round(Number(formData.get('mo_fontaneria') ?? 0) * 100), + electricidad: Math.round(Number(formData.get('mo_electricidad') ?? 0) * 100), + mano_de_obra: Math.round(Number(formData.get('mo_mano_de_obra') ?? 0) * 100), + }, + updatedAt: new Date(), + }) + .where(eq(pricingConfig.tenantId, tenantId)); + revalidatePath('/panel/precios'); +} + +export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] }; + +export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise { + const tenantId = await getTenantId(); + const csv = String(formData.get('csv') ?? ''); + const { rows, errors } = parseCatalogCsv(csv); + if (errors.length > 0) return { ok: false, inserted: 0, errors }; + + for (const r of rows) { + await db + .insert(catalogItems) + .values({ tenantId, ...r }) + .onConflictDoUpdate({ + target: [catalogItems.tenantId, catalogItems.sku], + set: { + categoria: r.categoria, + nombre: r.nombre, + calidad: r.calidad, + precioUnit: r.precioUnit, + unidad: r.unidad, + descriptorRender: r.descriptorRender, + }, + }); + } + revalidatePath('/panel/precios'); + return { ok: true, inserted: rows.length, errors: [] }; +} +``` + +- [ ] **Step 2: Crear la página del panel de precios** + +`mvp/b2c/src/app/panel/precios/page.tsx`: +```tsx +import { getPricingConfig, getCatalog } from '@/db/pricing-queries'; +import { formatEuros } from '@/lib/funnel'; +import { + crearMaterial, + actualizarPrecio, + borrarMaterial, + actualizarConfig, +} from './actions'; + +export const dynamic = 'force-dynamic'; + +const CATEGORIAS = ['suelo', 'pared', 'pintura', 'mobiliario'] as const; +const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = { + suelo: 'Suelos', + pared: 'Paredes / alicatado', + pintura: 'Pinturas', + mobiliario: 'Mobiliario', +}; + +export default async function PreciosPage() { + const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]); + + return ( +
+
+

Tabla de precios

+

+ Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a + partir de estos valores y las medidas del lead. +

+
+ + {/* Config general */} +
+

Configuración general

+
+ + {(['demolicion', 'fontaneria', 'electricidad', 'mano_de_obra'] as const).map((k) => ( + + ))} + +
+
+ + {/* Catálogo por categoría */} + {CATEGORIAS.map((categoria) => { + const items = catalog.filter((c) => c.categoria === categoria); + return ( +
+

{CATEGORIA_LABEL[categoria]}

+
+ {items.length === 0 &&

Sin materiales.

} + {items.map((item) => ( +
+ {item.nombre} + {item.calidad} + {item.unidad} + {item.esDefault && ( + default + )} +
+ + + + +
+
+ + +
+
+ ))} +
+ +
+ + + + + + + + + +
+
+ ); + })} + + {/* Import CSV */} +
+

Importar catálogo (CSV)

+

+ Cabecera: categoria,nombre,calidad,precio,unidad,descriptor_render,sku. El + precio en euros. Actualiza por SKU. +

+
void}> +