Files
reformix-hackaton/mvp/b2c/src/app/panel/page.tsx
Carlos Narro f09024f753 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>
2026-05-29 15:51:10 +02:00

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>
);
}