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:
213
mvp/b2c/src/app/panel/[id]/page.tsx
Normal file
213
mvp/b2c/src/app/panel/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
mvp/b2c/src/app/panel/actions.ts
Normal file
62
mvp/b2c/src/app/panel/actions.ts
Normal 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}`);
|
||||
}
|
||||
28
mvp/b2c/src/app/panel/layout.tsx
Normal file
28
mvp/b2c/src/app/panel/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
mvp/b2c/src/app/panel/page.tsx
Normal file
152
mvp/b2c/src/app/panel/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user