From 4106d586148fda2cb826e14165c34e448f01f118 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Sat, 30 May 2026 12:36:31 +0200 Subject: [PATCH] feat: add pricing panel with catalog CRUD and CSV import --- mvp/b2c/src/app/panel/layout.tsx | 9 +- mvp/b2c/src/app/panel/precios/actions.ts | 87 +++++++++++++ mvp/b2c/src/app/panel/precios/page.tsx | 148 +++++++++++++++++++++++ 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 mvp/b2c/src/app/panel/precios/actions.ts create mode 100644 mvp/b2c/src/app/panel/precios/page.tsx diff --git a/mvp/b2c/src/app/panel/layout.tsx b/mvp/b2c/src/app/panel/layout.tsx index e4722fb..0ccbe3d 100644 --- a/mvp/b2c/src/app/panel/layout.tsx +++ b/mvp/b2c/src/app/panel/layout.tsx @@ -19,7 +19,14 @@ export default function PanelLayout({ children }: { children: React.ReactNode }) / Reformas Ejemplo - Panel del reformista +
{children}
diff --git a/mvp/b2c/src/app/panel/precios/actions.ts b/mvp/b2c/src/app/panel/precios/actions.ts new file mode 100644 index 0000000..6765ed1 --- /dev/null +++ b/mvp/b2c/src/app/panel/precios/actions.ts @@ -0,0 +1,87 @@ +'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: [] }; +} diff --git a/mvp/b2c/src/app/panel/precios/page.tsx b/mvp/b2c/src/app/panel/precios/page.tsx new file mode 100644 index 0000000..6ab826c --- /dev/null +++ b/mvp/b2c/src/app/panel/precios/page.tsx @@ -0,0 +1,148 @@ +import { getPricingConfig, getCatalog } from '@/db/pricing-queries'; +import { formatEuros } from '@/lib/funnel'; +import { + crearMaterial, + actualizarPrecio, + borrarMaterial, + actualizarConfig, + importarCatalogoCsv, +} 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}> +