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>
153 lines
6.3 KiB
TypeScript
153 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|