Add B2B reformista panel with Postgres/Drizzle data layer

Modela el funnel del lead en dos dimensiones (pipeline_stage técnico
de 7 pasos + estado comercial de 6 estados) y siembra 11 leads demo,
uno por cada momento del funnel, para analizar el siguiente paso.
Incluye panel /panel (lista + detalle RF-D-01/02) y wiring de deploy
(Dockerfile multi-stage + entrypoint migrate+seed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-29 15:51:10 +02:00
parent 9020c24e68
commit f09024f753
20 changed files with 3630 additions and 2 deletions

View File

@@ -0,0 +1,213 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getLead } from '@/db/queries';
import EstadoControl from '@/components/panel/EstadoControl';
import {
PIPELINE_LABEL,
PIPELINE_NEXT,
PIPELINE_ORDER,
TIPO_LABEL,
formatEuros,
formatFecha,
} from '@/lib/funnel';
export const dynamic = 'force-dynamic';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3">
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">{title}</h2>
{children}
</section>
);
}
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getLead(id);
if (!data) notFound();
const { lead, fotos, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage));
return (
<div className="flex flex-col gap-6">
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
Volver a leads
</Link>
{/* Cabecera + estado */}
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-black tracking-tight text-black">{lead.nombre}</h1>
<p className="text-sm text-gray-500">
{lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '}
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
</p>
</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>
</div>
<EstadoControl
leadId={lead.id}
estado={lead.estado}
presupuestoEstimado={lead.presupuestoEstimado}
/>
</div>
{/* Timeline del funnel */}
<Section title="Progreso en el funnel">
<ol className="flex flex-col gap-2">
{PIPELINE_ORDER.map((stage) => {
const reached = reachedStages.has(stage);
const isCurrent = stage === lead.pipelineStage;
return (
<li key={stage} className="flex items-center gap-3 text-sm">
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
reached ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
<span className={reached ? 'text-black font-medium' : 'text-gray-400'}>
{PIPELINE_LABEL[stage]}
</span>
{isCurrent && (
<span className="ml-auto text-xs text-amber-600 font-medium">
{PIPELINE_NEXT[stage]}
</span>
)}
</li>
);
})}
</ol>
</Section>
<div className="grid md:grid-cols-2 gap-6">
{/* 1. Datos personales */}
<Section title="Datos personales">
<dl className="text-sm flex flex-col gap-2">
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Teléfono</dt>
<dd className="text-black font-medium">{lead.telefono}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Email</dt>
<dd className="text-black font-medium break-all">{lead.email}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Provincia</dt>
<dd className="text-black font-medium">{lead.provincia ?? '—'}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Consentimientos</dt>
<dd className="text-black font-medium">
{lead.consentPrivacidad ? 'Privacidad ✓' : 'Privacidad ✗'} ·{' '}
{lead.consentContratacion ? 'Contratación ✓' : 'Contratación ✗'}
</dd>
</div>
</dl>
</Section>
{/* 4. Render */}
<Section title="Render generado">
{lead.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={lead.renderUrl} alt="Render de la reforma" className="w-full rounded-lg object-cover" />
) : (
<p className="text-sm text-gray-400">Aún no generado.</p>
)}
</Section>
{/* 2. Transcripción */}
<Section title="Transcripción de la llamada">
{lead.transcripcion ? (
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap max-h-64 overflow-auto">
{lead.transcripcion}
</p>
) : (
<p className="text-sm text-gray-400">Aún no hay llamada.</p>
)}
</Section>
{/* 3. JSON de entidades */}
<Section title="Entidades extraídas (JSON)">
{lead.entidades ? (
<pre className="text-xs bg-gray-900 text-gray-100 rounded-lg p-4 overflow-auto max-h-64">
{JSON.stringify(lead.entidades, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-400">Sin entidades aún.</p>
)}
</Section>
{/* 5. Audio */}
<Section title="Audio de la llamada">
{lead.audioUrl ? (
<audio controls src={lead.audioUrl} className="w-full">
Tu navegador no soporta audio.
</audio>
) : (
<p className="text-sm text-gray-400">Sin grabación.</p>
)}
</Section>
{/* 6. PDF */}
<Section title="Presupuesto (PDF)">
{lead.pdfUrl ? (
<a
href={lead.pdfUrl}
download
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"
>
Descargar PDF
</a>
) : (
<p className="text-sm text-gray-400">Aún no generado.</p>
)}
</Section>
</div>
{/* Fotos subidas */}
{fotos.length > 0 && (
<Section title="Fotos subidas por el cliente">
<div className="flex flex-wrap gap-3">
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={f.id} src={f.url} alt="" className="w-32 h-24 object-cover rounded-lg border border-gray-200" />
))}
</div>
</Section>
)}
{/* Precisión (si ganado) */}
{precision && (
<Section title="Precisión del presupuesto">
<div className="flex flex-wrap gap-8 text-sm">
<div>
<div className="text-gray-400 text-xs">Estimado</div>
<div className="text-black font-bold text-lg">{formatEuros(precision.estimated)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Final firmado</div>
<div className="text-black font-bold text-lg">{formatEuros(precision.final)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Desviación</div>
<div
className={`font-bold text-lg ${
Math.abs(Number(precision.deltaPct)) <= 15 ? 'text-green-600' : 'text-amber-600'
}`}
>
{Number(precision.deltaPct) > 0 ? '+' : ''}
{precision.deltaPct}%
</div>
</div>
</div>
</Section>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { leads, leadEstadoHistory, precisionHistory, tenants } from '@/db/schema';
import { TENANT_SLUG } from '@/lib/funnel';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error('Tenant no encontrado.');
return tenant.id;
}
type Estado = (typeof leads.estado.enumValues)[number];
export async function cambiarEstado(leadId: string, estado: Estado) {
const tenantId = await getTenantId();
const [updated] = await db
.update(leads)
.set({ estado, updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.returning();
if (!updated) throw new Error('Lead no encontrado.');
await db.insert(leadEstadoHistory).values({ leadId, estado });
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function marcarGanado(leadId: string, precioFinalEuros: number) {
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.');
if (lead.presupuestoEstimado == null) {
throw new Error('El lead no tiene presupuesto estimado, no se puede calcular la precisión.');
}
const finalCents = Math.round(precioFinalEuros * 100);
const deltaPct = ((finalCents - lead.presupuestoEstimado) / lead.presupuestoEstimado) * 100;
await db.update(leads).set({ estado: 'ganado', updatedAt: new Date() }).where(eq(leads.id, leadId));
await db.insert(leadEstadoHistory).values({ leadId, estado: 'ganado' });
await db.insert(precisionHistory).values({
leadId,
estimated: lead.presupuestoEstimado,
final: finalCents,
deltaPct: deltaPct.toFixed(2),
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}

View File

@@ -0,0 +1,28 @@
import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Panel · Reformas Ejemplo',
description: 'Panel de leads del reformista',
};
export default function PanelLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/panel" className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
R
</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="text-gray-300">/</span>
<span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span>
</Link>
<span className="text-xs font-medium text-gray-400">Panel del reformista</span>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import Link from 'next/link';
import { getLeads, getResumen, type LeadFiltro } from '@/db/queries';
import {
ESTADOS,
ESTADO_BADGE,
ESTADO_LABEL,
PIPELINE_LABEL,
PIPELINE_NEXT,
formatEuros,
formatFecha,
} from '@/lib/funnel';
export const dynamic = 'force-dynamic';
const FILTROS: { value: LeadFiltro; label: string }[] = [
{ value: 'todos', label: 'Todos' },
...ESTADOS.map((e) => ({ value: e as LeadFiltro, label: ESTADO_LABEL[e] })),
];
export default async function PanelPage({
searchParams,
}: {
searchParams: Promise<{ estado?: string }>;
}) {
const { estado } = await searchParams;
const filtro: LeadFiltro = (FILTROS.find((f) => f.value === estado)?.value ?? 'todos') as LeadFiltro;
const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-black tracking-tight text-black">Leads</h1>
<p className="text-sm text-gray-500">
{resumen.total} leads en total · {resumen.porEstado['nuevo'] ?? 0} sin contactar
</p>
</div>
{/* Filtros por estado */}
<div className="flex flex-wrap gap-2">
{FILTROS.map((f) => {
const active = f.value === filtro;
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
return (
<Link
key={f.value}
href={f.value === 'todos' ? '/panel' : `/panel?estado=${f.value}`}
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
active
? 'bg-black text-white border-black'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
}`}
>
{f.label} <span className={active ? 'text-gray-300' : 'text-gray-400'}>{count}</span>
</Link>
);
})}
</div>
{/* Tabla (desktop) */}
<div className="hidden md:block bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3 font-semibold">Render</th>
<th className="px-4 py-3 font-semibold">Cliente</th>
<th className="px-4 py-3 font-semibold">Fecha</th>
<th className="px-4 py-3 font-semibold">Estado</th>
<th className="px-4 py-3 font-semibold">Presupuesto</th>
<th className="px-4 py-3 font-semibold">Siguiente paso</th>
</tr>
</thead>
<tbody>
{leads.map((l) => (
<tr key={l.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="block w-16 h-12 rounded-md overflow-hidden bg-gray-100">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="flex w-full h-full items-center justify-center text-[10px] text-gray-400">
sin render
</span>
)}
</Link>
</td>
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="font-semibold text-black hover:underline">
{l.nombre}
</Link>
<div className="text-gray-500">{l.telefono}</div>
</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatFecha(l.createdAt)}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2.5 py-1 rounded-full text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</td>
<td className="px-4 py-3 font-semibold text-black whitespace-nowrap">
{formatEuros(l.presupuestoEstimado)}
</td>
<td className="px-4 py-3 text-gray-500">
<div className="text-xs text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</div>
{PIPELINE_NEXT[l.pipelineStage]}
</td>
</tr>
))}
</tbody>
</table>
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
{/* Cards (mobile) */}
<div className="md:hidden flex flex-col gap-3">
{leads.map((l) => (
<Link
key={l.id}
href={`/panel/${l.id}`}
className="bg-white border border-gray-200 rounded-xl p-4 flex gap-3"
>
<div className="w-16 h-16 rounded-md overflow-hidden bg-gray-100 shrink-0">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : null}
</div>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-black truncate">{l.nombre}</span>
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</div>
<div className="text-sm text-gray-500">{l.telefono}</div>
<div className="flex items-center justify-between text-sm">
<span className="font-semibold text-black">{formatEuros(l.presupuestoEstimado)}</span>
<span className="text-gray-400">{formatFecha(l.createdAt)}</span>
</div>
<div className="text-xs text-gray-400">{PIPELINE_NEXT[l.pipelineStage]}</div>
</div>
</Link>
))}
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
</div>
);
}