feat: wire computeBudget into recalcularPresupuesto and show desglose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-30 12:41:40 +02:00
parent 4106d58614
commit 588aa4dc1c
2 changed files with 143 additions and 1 deletions

View File

@@ -10,6 +10,8 @@ import {
formatEuros, formatEuros,
formatFecha, formatFecha,
} from '@/lib/funnel'; } from '@/lib/funnel';
import { recalcularPresupuesto } from '../actions';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -30,6 +32,9 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const { lead, fotos, eventos, precision } = data; const { lead, fotos, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage)); const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit"> <Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
@@ -208,6 +213,98 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
</div> </div>
</Section> </Section>
)} )}
{/* Presupuesto desglosado */}
<Section title="Presupuesto desglosado">
<form action={recalcularPresupuesto.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Recalcular presupuesto
</button>
</form>
{desglose ? (
<div className="flex flex-col gap-4 mt-2">
{/* Partidas */}
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 uppercase tracking-wide border-b border-gray-100">
<th className="pb-2 font-semibold">Partida</th>
<th className="pb-2 font-semibold text-right">Importe</th>
</tr>
</thead>
<tbody>
{desglose.partidas.map((partida) => (
<tr key={partida.key} className="border-b border-gray-50">
<td className="py-1.5 text-gray-700">{partida.label}</td>
<td className="py-1.5 text-right text-black font-medium">
{formatEuros(partida.importe)}
</td>
</tr>
))}
</tbody>
</table>
{/* Subtotal, factor zona, total */}
<div className="flex flex-col gap-1 text-sm border-t border-gray-200 pt-3">
<div className="flex justify-between">
<span className="text-gray-500">Subtotal</span>
<span className="text-black font-medium">{formatEuros(desglose.subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Factor de zona</span>
<span className="text-black font-medium">×{desglose.factorZona.toFixed(2)}</span>
</div>
<div className="flex justify-between mt-1 pt-2 border-t border-gray-200">
<span className="text-black font-bold">Total estimado</span>
<span className="text-black font-bold text-lg">{formatEuros(desglose.total)}</span>
</div>
</div>
{/* Rango */}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Rango orientativo</span>
<span className="text-black font-medium">
{formatEuros(desglose.rango.min)} {formatEuros(desglose.rango.max)}
</span>
</div>
{/* Confianza */}
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Confianza del cálculo</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
desglose.confianza === 'alta'
? 'bg-green-100 text-green-700'
: desglose.confianza === 'media'
? 'bg-amber-100 text-amber-700'
: 'bg-red-100 text-red-700'
}`}
>
{desglose.confianza}
</span>
</div>
{/* Avisos */}
{desglose.avisos.length > 0 && (
<ul className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3 flex flex-col gap-1">
{desglose.avisos.map((aviso, i) => (
<li key={i}> {aviso}</li>
))}
</ul>
)}
{/* Disclaimer RF-B-09 */}
<p className="text-xs text-gray-400">
Presupuesto orientativo. El precio final puede variar según la visita técnica.
</p>
</div>
) : (
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>
)}
</Section>
</div> </div>
); );
} }

View File

@@ -3,8 +3,11 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { db } from '@/db'; import { db } from '@/db';
import { leads, leadEstadoHistory, precisionHistory, tenants } from '@/db/schema'; import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory, tenants } from '@/db/schema';
import { TENANT_SLUG } from '@/lib/funnel'; import { TENANT_SLUG } from '@/lib/funnel';
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import type { BudgetInputs } from '@/budget/types';
async function getTenantId(): Promise<string> { async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1); const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
@@ -60,3 +63,45 @@ export async function marcarGanado(leadId: string, precioFinalEuros: number) {
revalidatePath('/panel'); revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`); revalidatePath(`/panel/${leadId}`);
} }
export async function recalcularPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
const inputs: BudgetInputs = {
tipoReforma: lead.tipoReforma ?? 'otro',
m2Suelo: lead.m2Suelo ?? null,
alturaTecho: lead.alturaTecho ?? null,
calidadGlobal: lead.calidadGlobal ?? 'media',
estructural: lead.estructural,
provincia: lead.provincia ?? null,
materialSelections: (lead.materialSelections as Record<string, string>) ?? {},
};
const result = computeBudget(inputs, config, catalog);
await db
.update(leads)
.set({
presupuestoEstimado: result.total,
desgloseSnapshot: { stage: lead.pipelineStage, result },
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'presupuesto_generado',
metadata: { total: result.total, confianza: result.confianza },
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}