feat: add pricing panel with catalog CRUD and CSV import
This commit is contained in:
@@ -19,7 +19,14 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
|
|||||||
<span className="text-gray-300">/</span>
|
<span className="text-gray-300">/</span>
|
||||||
<span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span>
|
<span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span>
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs font-medium text-gray-400">Panel del reformista</span>
|
<nav className="flex items-center gap-4 text-xs font-medium">
|
||||||
|
<Link href="/panel" className="text-gray-500 hover:text-black">
|
||||||
|
Leads
|
||||||
|
</Link>
|
||||||
|
<Link href="/panel/precios" className="text-gray-500 hover:text-black">
|
||||||
|
Precios
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
|
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
|
||||||
|
|||||||
87
mvp/b2c/src/app/panel/precios/actions.ts
Normal file
87
mvp/b2c/src/app/panel/precios/actions.ts
Normal file
@@ -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<ImportResult> {
|
||||||
|
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: [] };
|
||||||
|
}
|
||||||
148
mvp/b2c/src/app/panel/precios/page.tsx
Normal file
148
mvp/b2c/src/app/panel/precios/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight text-black">Tabla de precios</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
|
||||||
|
partir de estos valores y las medidas del lead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config general */}
|
||||||
|
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h2 className="font-bold text-black mb-4">Configuración general</h2>
|
||||||
|
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-gray-500 mb-1">Altura techo (m)</span>
|
||||||
|
<input
|
||||||
|
name="alturaTechoDefault"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={config.alturaTechoDefault}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-2 py-1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(['demolicion', 'fontaneria', 'electricidad', 'mano_de_obra'] as const).map((k) => (
|
||||||
|
<label key={k} className="text-sm">
|
||||||
|
<span className="block text-gray-500 mb-1">M.O. {k} (€/m²)</span>
|
||||||
|
<input
|
||||||
|
name={`mo_${k}`}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
defaultValue={(config.manoObra[k] ?? 0) / 100}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-2 py-1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button className="col-span-2 md:col-span-5 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||||
|
Guardar configuración
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Catálogo por categoría */}
|
||||||
|
{CATEGORIAS.map((categoria) => {
|
||||||
|
const items = catalog.filter((c) => c.categoria === categoria);
|
||||||
|
return (
|
||||||
|
<section key={categoria} className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h2 className="font-bold text-black mb-4">{CATEGORIA_LABEL[categoria]}</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.length === 0 && <p className="text-sm text-gray-400">Sin materiales.</p>}
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center gap-3 text-sm border-b border-gray-100 pb-2">
|
||||||
|
<span className="w-48 font-medium text-black">{item.nombre}</span>
|
||||||
|
<span className="w-20 text-gray-500 capitalize">{item.calidad}</span>
|
||||||
|
<span className="w-16 text-gray-400">{item.unidad}</span>
|
||||||
|
{item.esDefault && (
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 rounded px-1.5 py-0.5">default</span>
|
||||||
|
)}
|
||||||
|
<form action={actualizarPrecio} className="flex items-center gap-2 ml-auto">
|
||||||
|
<input type="hidden" name="id" value={item.id} />
|
||||||
|
<input
|
||||||
|
name="precioEuros"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
defaultValue={item.precioUnit / 100}
|
||||||
|
className="w-24 border border-gray-300 rounded-lg px-2 py-1 text-right"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400">€</span>
|
||||||
|
<button className="text-xs text-blue-600 hover:underline">Guardar</button>
|
||||||
|
</form>
|
||||||
|
<form action={borrarMaterial}>
|
||||||
|
<input type="hidden" name="id" value={item.id} />
|
||||||
|
<button className="text-xs text-red-500 hover:underline">Borrar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={crearMaterial} className="mt-4 flex flex-wrap items-end gap-2 text-sm">
|
||||||
|
<input type="hidden" name="categoria" value={categoria} />
|
||||||
|
<input name="nombre" placeholder="Nombre" required className="border border-gray-300 rounded-lg px-2 py-1" />
|
||||||
|
<select name="calidad" className="border border-gray-300 rounded-lg px-2 py-1">
|
||||||
|
<option value="basica">Básica</option>
|
||||||
|
<option value="media">Media</option>
|
||||||
|
<option value="premium">Premium</option>
|
||||||
|
</select>
|
||||||
|
<input name="precioEuros" type="number" step="0.01" placeholder="€" required className="w-24 border border-gray-300 rounded-lg px-2 py-1" />
|
||||||
|
<select name="unidad" className="border border-gray-300 rounded-lg px-2 py-1">
|
||||||
|
<option value="m2">m²</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="ud">ud</option>
|
||||||
|
</select>
|
||||||
|
<input name="descriptorRender" placeholder="Descriptor render" className="flex-1 min-w-40 border border-gray-300 rounded-lg px-2 py-1" />
|
||||||
|
<input name="sku" placeholder="SKU" required className="w-28 border border-gray-300 rounded-lg px-2 py-1" />
|
||||||
|
<label className="flex items-center gap-1 text-gray-500">
|
||||||
|
<input type="checkbox" name="esDefault" /> default
|
||||||
|
</label>
|
||||||
|
<button className="bg-black text-white rounded-lg px-3 py-1 font-medium">Añadir</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Import CSV */}
|
||||||
|
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h2 className="font-bold text-black mb-2">Importar catálogo (CSV)</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Cabecera: <code>categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El
|
||||||
|
precio en euros. Actualiza por SKU.
|
||||||
|
</p>
|
||||||
|
<form action={importarCatalogoCsv as unknown as (fd: FormData) => void}>
|
||||||
|
<textarea
|
||||||
|
name="csv"
|
||||||
|
rows={5}
|
||||||
|
placeholder="categoria,nombre,calidad,precio,unidad,descriptor_render,sku"
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<button className="mt-2 bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||||
|
Importar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user