Files
reformix-hackaton/mvp/b2c/src/components/panel/LeadsView.tsx
Carlos Narro bf9e72064b Alinear panel y auth con la identidad B2B "Architectural Warmth"
Sustituye la paleta negra/azul B2C del panel del reformista por el verde
de marca, neutros cálidos y titulares en Instrument Serif de la landing B2B.
Añade tokens --color-primary-*, --color-stone-50 y --font-display al @theme.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:01:57 +02:00

280 lines
9.4 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useSyncExternalStore } from 'react';
import {
CALIDAD_LABEL,
ESTADO_BADGE,
ESTADO_LABEL,
PIPELINE_LABEL,
PIPELINE_NEXT,
TIPO_LABEL,
formatEuros,
formatFecha,
formatRelativo,
} from '@/lib/funnel';
export type PanelLead = {
id: string;
nombre: string;
telefono: string;
provincia: string | null;
tipoReforma: keyof typeof TIPO_LABEL | null;
estado: keyof typeof ESTADO_BADGE;
pipelineStage: keyof typeof PIPELINE_NEXT;
presupuestoEstimado: number | null;
renderUrl: string | null;
createdAtMs: number;
m2Suelo: number | null;
calidadGlobal: keyof typeof CALIDAD_LABEL | null;
};
type Vista = 'cards' | 'tabla';
const STORAGE_KEY = 'reformix.panel.leadsVista';
// La preferencia de vista vive en localStorage; useSyncExternalStore la lee sin
// provocar desajuste de hidratación (el servidor siempre renderiza 'cards').
const listeners = new Set<() => void>();
function subscribeVista(cb: () => void) {
listeners.add(cb);
window.addEventListener('storage', cb);
return () => {
listeners.delete(cb);
window.removeEventListener('storage', cb);
};
}
function getVistaSnapshot(): Vista {
return window.localStorage.getItem(STORAGE_KEY) === 'tabla' ? 'tabla' : 'cards';
}
function getVistaServerSnapshot(): Vista {
return 'cards';
}
function setVistaPersistida(v: Vista) {
window.localStorage.setItem(STORAGE_KEY, v);
for (const cb of listeners) cb();
}
function iniciales(nombre: string): string {
const partes = nombre.trim().split(/\s+/).slice(0, 2);
return partes.map((p) => p[0]?.toUpperCase() ?? '').join('') || '?';
}
function subtitulo(l: PanelLead): string {
const partes = [
l.tipoReforma ? TIPO_LABEL[l.tipoReforma] : null,
l.provincia,
].filter(Boolean);
return partes.join(' · ');
}
function detalle(l: PanelLead): string {
const partes = [
formatRelativo(new Date(l.createdAtMs)),
l.m2Suelo ? `${l.m2Suelo}` : null,
l.calidadGlobal ? CALIDAD_LABEL[l.calidadGlobal] : null,
].filter(Boolean);
return partes.join(' · ');
}
function ToggleVista({ vista, onChange }: { vista: Vista; onChange: (v: Vista) => void }) {
const opciones: { value: Vista; label: string; icon: React.ReactNode }[] = [
{
value: 'cards',
label: 'Tarjetas',
icon: (
<>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</>
),
},
{
value: 'tabla',
label: 'Tabla',
icon: (
<>
<path d="M3 5h18M3 12h18M3 19h18" />
</>
),
},
];
return (
<div className="inline-flex rounded-lg border border-gray-200 bg-white p-0.5">
{opciones.map((o) => {
const activo = vista === o.value;
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
aria-pressed={activo}
className={
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ' +
(activo ? 'bg-primary-700 text-white' : 'text-gray-500 hover:text-primary-700')
}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{o.icon}
</svg>
{o.label}
</button>
);
})}
</div>
);
}
function Tarjeta({ l }: { l: PanelLead }) {
return (
<Link
href={`/panel/${l.id}`}
className="group flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 transition hover:border-gray-400 hover:shadow-sm"
>
<div className="flex items-start gap-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary-700 text-sm font-bold text-white">
{iniciales(l.nombre)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<span className="truncate font-semibold text-gray-900">{l.nombre}</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}
>
{ESTADO_LABEL[l.estado]}
</span>
</div>
<p className="truncate text-sm text-gray-500">{subtitulo(l) || l.telefono}</p>
<p className="mt-0.5 truncate text-xs text-gray-400">{detalle(l)}</p>
</div>
</div>
{l.renderUrl && (
<div className="overflow-hidden rounded-lg bg-gray-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={l.renderUrl}
alt=""
className="aspect-[16/9] w-full object-cover transition group-hover:scale-[1.02]"
/>
</div>
)}
<div className="flex items-end justify-between border-t border-gray-100 pt-3">
<div className="min-w-0">
<p className="text-[11px] text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</p>
<p className="truncate text-xs text-gray-500">{PIPELINE_NEXT[l.pipelineStage]}</p>
</div>
<div className="shrink-0 text-right">
<p className="font-bold text-gray-900">{formatEuros(l.presupuestoEstimado)}</p>
{l.presupuestoEstimado != null && (
<p className="text-[11px] text-gray-400">orientativo</p>
)}
</div>
</div>
</Link>
);
}
export default function LeadsView({ leads }: { leads: PanelLead[] }) {
const vista = useSyncExternalStore(subscribeVista, getVistaSnapshot, getVistaServerSnapshot);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<ToggleVista vista={vista} onChange={setVistaPersistida} />
</div>
{leads.length === 0 ? (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-12 text-center text-gray-400">
No hay leads con este estado.
</div>
) : vista === 'cards' ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{leads.map((l) => (
<Tarjeta key={l.id} l={l} />
))}
</div>
) : (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<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 h-12 w-16 overflow-hidden rounded-md bg-gray-100"
>
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-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">{subtitulo(l) || l.telefono}</div>
</td>
<td className="whitespace-nowrap px-4 py-3 text-gray-600">
{formatFecha(new Date(l.createdAtMs))}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2.5 py-1 text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}
>
{ESTADO_LABEL[l.estado]}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 font-semibold text-black">
{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>
</div>
)}
</div>
);
}