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>
280 lines
9.4 KiB
TypeScript
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} m²` : 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>
|
|
);
|
|
}
|