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:
@@ -186,6 +186,7 @@ CREATE TABLE "pricing_config" (
|
|||||||
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
"extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::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,
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
|
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
|
||||||
);
|
);
|
||||||
|
|||||||
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "pricing_config" ADD COLUMN "baremo_minimo" integer;
|
||||||
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
|||||||
"when": 1780593183911,
|
"when": 1780593183911,
|
||||||
"tag": "0011_warm_post",
|
"tag": "0011_warm_post",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781189893331,
|
||||||
|
"tag": "0012_lame_sentinel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getLead } from '@/db/queries';
|
import { getLead } from '@/db/queries';
|
||||||
|
import { getPricingConfigFor } from '@/db/pricing-queries';
|
||||||
import EstadoControl from '@/components/panel/EstadoControl';
|
import EstadoControl from '@/components/panel/EstadoControl';
|
||||||
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||||
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
|
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 prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
|
||||||
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
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 h = await headers();
|
||||||
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
||||||
const proto = h.get('x-forwarded-proto') ?? 'https';
|
const proto = h.get('x-forwarded-proto') ?? 'https';
|
||||||
@@ -64,7 +73,14 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xs text-gray-400">Presupuesto estimado</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<EstadoControl
|
<EstadoControl
|
||||||
|
|||||||
@@ -93,6 +93,19 @@ export async function actualizarExtras(formData: FormData) {
|
|||||||
revalidatePath('/panel/precios');
|
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) {
|
export async function actualizarEnvio(formData: FormData) {
|
||||||
const tenantId = await getTenantId();
|
const tenantId = await getTenantId();
|
||||||
const modo = formData.get('modo');
|
const modo = formData.get('modo');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
borrarMaterial,
|
borrarMaterial,
|
||||||
actualizarConfig,
|
actualizarConfig,
|
||||||
actualizarExtras,
|
actualizarExtras,
|
||||||
|
actualizarBaremo,
|
||||||
actualizarEnvio,
|
actualizarEnvio,
|
||||||
importarCatalogoCsv,
|
importarCatalogoCsv,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
@@ -120,6 +121,34 @@ export default async function PreciosPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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 */}
|
{/* Extras fijos */}
|
||||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
|
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface PricingConfig {
|
|||||||
factorZona: Record<string, number>; // provincia -> multiplicador
|
factorZona: Record<string, number>; // provincia -> multiplicador
|
||||||
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
||||||
extras?: ExtrasFijos; // importes fijos en céntimos
|
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 {
|
export interface BudgetInputs {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
|||||||
factorZona: {},
|
factorZona: {},
|
||||||
manoObra: { ...MANO_OBRA_DEFAULT },
|
manoObra: { ...MANO_OBRA_DEFAULT },
|
||||||
extras: { ...EXTRAS_DEFAULT },
|
extras: { ...EXTRAS_DEFAULT },
|
||||||
|
baremoMinimo: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +50,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
|||||||
factorZona: row.factorZona,
|
factorZona: row.factorZona,
|
||||||
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
||||||
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
|
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
|
||||||
|
baremoMinimo: row.baremoMinimo ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,10 @@ export const pricingConfig = pgTable('pricing_config', {
|
|||||||
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
|
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
|
.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(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user