feat: wire computeBudget into recalcularPresupuesto and show desglose
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ import {
|
||||
formatEuros,
|
||||
formatFecha,
|
||||
} from '@/lib/funnel';
|
||||
import { recalcularPresupuesto } from '../actions';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
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 reachedStages = new Set(eventos.map((e) => e.stage));
|
||||
|
||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||
const desglose = snapshot?.result ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
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 { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
import type { BudgetInputs } from '@/budget/types';
|
||||
|
||||
async function getTenantId(): Promise<string> {
|
||||
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/${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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user