Añade baremo de rentabilidad (valor de panel) e indicador en la ficha del lead

Parte C del plan: el baremo mínimo de rentabilidad es ahora un valor configurable
del reformista, solo informativo. Los agentes NO lo usan para decidir nada.

- schema: pricing_config.baremo_minimo (céntimos, nullable) + migración 0012.
- pricing-queries / budget types: exponen baremoMinimo.
- panel/precios: sección "Baremo de rentabilidad" + action actualizarBaremo
  (vacío = sin baremo).
- panel/[id]: el presupuesto estimado se muestra en rojo con aviso "Por debajo
  de tu baremo (X €)" cuando no alcanza el baremo del tenant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-11 16:58:55 +02:00
parent 0c033eb367
commit b815b0532b
10 changed files with 2533 additions and 1 deletions

View File

@@ -186,6 +186,7 @@ CREATE TABLE "pricing_config" (
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
"extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL,
"baremo_minimo" integer,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
);

View File

@@ -0,0 +1 @@
ALTER TABLE "pricing_config" ADD COLUMN "baremo_minimo" integer;

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1780593183911,
"tag": "0011_warm_post",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781189893331,
"tag": "0012_lame_sentinel",
"breakpoints": true
}
]
}

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { getLead } from '@/db/queries';
import { getPricingConfigFor } from '@/db/pricing-queries';
import EstadoControl from '@/components/panel/EstadoControl';
import ConceptosEditor from '@/components/panel/ConceptosEditor';
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
@@ -41,6 +42,14 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
// Baremo de rentabilidad del reformista (informativo): si el presupuesto estimado no lo alcanza,
// se marca en rojo. null = sin baremo o sin presupuesto aún (no se marca nada).
const baremoMinimo = (await getPricingConfigFor(lead.tenantId)).baremoMinimo ?? null;
const pasaBaremo =
baremoMinimo != null && lead.presupuestoEstimado != null
? lead.presupuestoEstimado >= baremoMinimo
: null;
const h = await headers();
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
const proto = h.get('x-forwarded-proto') ?? 'https';
@@ -64,7 +73,14 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
</div>
<div className="text-right">
<div className="text-xs text-gray-400">Presupuesto estimado</div>
<div className="text-2xl font-black text-black">{formatEuros(lead.presupuestoEstimado)}</div>
<div className={`text-2xl font-black ${pasaBaremo === false ? 'text-red-600' : 'text-black'}`}>
{formatEuros(lead.presupuestoEstimado)}
</div>
{pasaBaremo === false && baremoMinimo != null && (
<div className="mt-0.5 text-xs font-semibold text-red-600">
Por debajo de tu baremo ({formatEuros(baremoMinimo)})
</div>
)}
</div>
</div>
<EstadoControl

View File

@@ -93,6 +93,19 @@ export async function actualizarExtras(formData: FormData) {
revalidatePath('/panel/precios');
}
export async function actualizarBaremo(formData: FormData) {
const tenantId = await getTenantId();
const raw = formData.get('baremoMinimo');
const txt = typeof raw === 'string' ? raw.trim() : '';
// Vacío = sin baremo (null). Con valor = euros → céntimos.
const baremoMinimo = txt === '' ? null : eurosToCents(raw, 'baremo de rentabilidad');
await db
.update(pricingConfig)
.set({ baremoMinimo, updatedAt: new Date() })
.where(eq(pricingConfig.tenantId, tenantId));
revalidatePath('/panel/precios');
}
export async function actualizarEnvio(formData: FormData) {
const tenantId = await getTenantId();
const modo = formData.get('modo');

View File

@@ -5,6 +5,7 @@ import {
borrarMaterial,
actualizarConfig,
actualizarExtras,
actualizarBaremo,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
@@ -120,6 +121,34 @@ export default async function PreciosPage() {
</form>
</section>
{/* Baremo de rentabilidad */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Baremo de rentabilidad</h2>
<p className="text-sm text-gray-500 mb-4">
Importe mínimo de un trabajo para que te resulte rentable. Es solo orientativo para ti: en la
ficha de cada lead verás marcados en otro color los presupuestos que no lleguen a este valor.
No afecta a lo que ve el cliente ni a la conversación de los agentes. Déjalo vacío para no usar
baremo.
</p>
<form action={actualizarBaremo} className="flex flex-wrap items-end gap-3">
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Baremo mínimo ()</span>
<input
name="baremoMinimo"
type="number"
step="1"
min="0"
defaultValue={config.baremoMinimo != null ? config.baremoMinimo / 100 : ''}
placeholder="Sin baremo"
className="w-40 border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
<button className="bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar baremo
</button>
</form>
</section>
{/* Extras fijos */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Extras fijos</h2>

View File

@@ -46,6 +46,7 @@ export interface PricingConfig {
factorZona: Record<string, number>; // provincia -> multiplicador
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
extras?: ExtrasFijos; // importes fijos en céntimos
baremoMinimo?: number | null; // céntimos; trabajo mínimo rentable (informativo, no lo usan los agentes)
}
export interface BudgetInputs {

View File

@@ -42,6 +42,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
factorZona: {},
manoObra: { ...MANO_OBRA_DEFAULT },
extras: { ...EXTRAS_DEFAULT },
baremoMinimo: null,
};
}
return {
@@ -49,6 +50,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
factorZona: row.factorZona,
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
baremoMinimo: row.baremoMinimo ?? null,
};
}

View File

@@ -396,6 +396,10 @@ export const pricingConfig = pgTable('pricing_config', {
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
.notNull()
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
// Baremo de rentabilidad (céntimos): importe mínimo que el reformista considera rentable. Solo
// informativo en el panel (marca en otro color los leads por debajo); los agentes NO lo usan para
// decidir nada. Null = sin baremo configurado.
baremoMinimo: integer('baremo_minimo'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});