Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt around the panel's own colors instead of an invented palette. Landing (`src/components/landing/`, one section per file, no props): - Seven funnel sections composed by `LandingPage`. The hero's eight swatches are literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands to avatars and services, so "your colors become your numbers" is literal. - `BrandMark` becomes the single source for the logo, replicating `public/favicon.svg`. Blue is the action surface, orange only ever marks. - `NotebookVisual` is the one deliberate exception to the palette: it is what the product replaces. - Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a single easing, and reduced-motion resolves `initial` to the final state so a never-firing `whileInView` cannot leave a section invisible forever. Routing and bundle: - `homePathFor` is the single definition of each role's destination. - Landing, login, dashboard and calendar load lazily. Eager, the login dragged framer-motion (~40 KB gz) into every panel load and the landing downloaded recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is pinned to the `react` chunk because Rollup otherwise assigns it to `charts`, making the entry import 111 KB gz for a 200-byte utility. Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`: - No touch form field below 16px, `dvh` height utilities, safe-area insets, and 40px touch targets keyed off `pointer: coarse` rather than `sm:`. - New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`, so it left the login's side padding at 0 on anything but an iPhone in landscape. No overflow check could see it — there was no overflow, just zero margin. The audit now guards it with a `gutter` check. - `shell-height` no longer fires on pages that legitimately scroll; the static `raw-viewport-unit` scan covers those instead. Production build: - The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time constant, so without it Vite eliminated the magic login and the "Ver como…" switcher: the deployed landing promised "no registration" and led to an empty form. Verified by building both ways and diffing the bundle. - Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged from returning visitors' caches. Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12, landing 13/13 (WebKit), PWA passed against the real production build. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
585 lines
23 KiB
TypeScript
585 lines
23 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
CartesianGrid,
|
|
} from "recharts";
|
|
import {
|
|
DollarSign,
|
|
CalendarCheck,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
Crown,
|
|
Star,
|
|
Scissors,
|
|
Ticket as TicketIcon,
|
|
Flame,
|
|
Trophy,
|
|
Coins,
|
|
Wallet,
|
|
} from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { useAuth } from "../lib/auth";
|
|
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
|
|
import {
|
|
formatCurrency,
|
|
formatNumber,
|
|
formatDate,
|
|
paymentLabel,
|
|
cn,
|
|
} from "../lib/format";
|
|
|
|
const RANGES = [
|
|
{ value: "7", label: "7 días" },
|
|
{ value: "30", label: "30 días" },
|
|
{ value: "60", label: "60 días" },
|
|
];
|
|
|
|
const PIE_COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
|
|
|
|
export function DashboardPage() {
|
|
const { business } = useAuth();
|
|
const [range, setRange] = useState("30");
|
|
const symbol = business?.currency_symbol ?? "$";
|
|
|
|
const { data: overview, isLoading: ovLoading } = useQuery({
|
|
queryKey: ["dashboard", "overview", range],
|
|
queryFn: () => api.dashboard.overview(Number(range)),
|
|
});
|
|
const { data: emp } = useQuery({
|
|
queryKey: ["dashboard", "best-employees", range],
|
|
queryFn: () => api.dashboard.bestEmployees(range),
|
|
});
|
|
const { data: svc } = useQuery({
|
|
queryKey: ["dashboard", "best-services", range],
|
|
queryFn: () => api.dashboard.bestServices(range),
|
|
});
|
|
const { data: tickets } = useQuery({
|
|
queryKey: ["dashboard", "top-tickets", range],
|
|
queryFn: () => api.dashboard.topTickets(range, 6),
|
|
});
|
|
const { data: topClients } = useQuery({
|
|
queryKey: ["dashboard", "top-clients", range],
|
|
queryFn: () => api.dashboard.topClients(range, 6),
|
|
});
|
|
const { data: freqClients } = useQuery({
|
|
queryKey: ["dashboard", "frequent-clients", range],
|
|
queryFn: () => api.dashboard.frequentClients(range, 6),
|
|
});
|
|
const { data: trend } = useQuery({
|
|
queryKey: ["dashboard", "trend", range],
|
|
queryFn: () => api.dashboard.revenueTrend(Number(range)),
|
|
});
|
|
const { data: cats } = useQuery({
|
|
queryKey: ["dashboard", "categories", range],
|
|
queryFn: () => api.dashboard.revenueByCategory(range),
|
|
});
|
|
const { data: commissions } = useQuery({
|
|
queryKey: ["dashboard", "commissions", range],
|
|
queryFn: () => api.dashboard.commissions(range),
|
|
});
|
|
|
|
const trendData = (trend?.points ?? []).map((p) => ({
|
|
...p,
|
|
label: new Date(p.date).toLocaleDateString("es-MX", { day: "numeric", month: range === "7" ? "short" : undefined }),
|
|
}));
|
|
|
|
const totalCat = (cats?.slices ?? []).reduce((a, s) => a + s.revenue, 0) || 1;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<PageHeader
|
|
title="Tablero"
|
|
subtitle={`Rendimiento de ${business?.name ?? "tu negocio"}`}
|
|
actions={
|
|
<div className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
|
|
{RANGES.map((r) => (
|
|
<button
|
|
key={r.value}
|
|
onClick={() => setRange(r.value)}
|
|
className={cn(
|
|
"tap-target rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
|
|
range === r.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
|
|
)}
|
|
>
|
|
{r.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
|
|
{/* KPIs */}
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
|
<KpiCard
|
|
title="Ingresos de hoy"
|
|
value={ovLoading ? "…" : formatCurrency(overview?.revenue_today ?? 0, symbol)}
|
|
sub="día de hoy"
|
|
icon={Wallet}
|
|
color="#10b981"
|
|
/>
|
|
<KpiCard
|
|
title="Ingresos del período"
|
|
value={ovLoading ? "…" : formatCurrency(overview?.revenue_range ?? 0, symbol)}
|
|
sub={`últimos ${range} días`}
|
|
delta={overview?.revenue_change_pct}
|
|
deltaSuffix="% vs período anterior"
|
|
icon={DollarSign}
|
|
color="#3b66ff"
|
|
/>
|
|
<KpiCard
|
|
title="Citas programadas"
|
|
value={ovLoading ? "…" : formatNumber(overview?.appts_upcoming ?? 0)}
|
|
sub="próximas"
|
|
icon={CalendarCheck}
|
|
color="#6366f1"
|
|
/>
|
|
<KpiCard
|
|
title="Ticket promedio"
|
|
value={ovLoading ? "…" : formatCurrency(overview?.avg_ticket ?? 0, symbol)}
|
|
sub="por venta"
|
|
icon={TrendingUp}
|
|
color="#a855f7"
|
|
/>
|
|
<KpiCard
|
|
title="Finalización"
|
|
value={ovLoading ? "…" : `${overview?.completion_pct ?? 0}%`}
|
|
sub={`${overview?.cancel_rate ?? 0}% cancelación`}
|
|
icon={Flame}
|
|
color="#f17616"
|
|
/>
|
|
</div>
|
|
|
|
{/* Revenue chart + Category pie */}
|
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
|
<div className="card p-5 lg:col-span-2">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-bold text-slate-900">Ingresos del período</h3>
|
|
<p className="text-xs text-slate-500">Tendencia diaria</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs font-bold text-brand-600">
|
|
<div className="h-2 w-2 rounded-full bg-brand-500" />
|
|
Ingresos
|
|
</div>
|
|
</div>
|
|
{!trend ? (
|
|
<div className="flex h-64 items-center justify-center"><Spinner /></div>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<AreaChart data={trendData} margin={{ left: -10, right: 8, top: 4 }}>
|
|
<defs>
|
|
<linearGradient id="rev" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#3b66ff" stopOpacity={0.35} />
|
|
<stop offset="100%" stopColor="#3b66ff" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid stroke="#eef0f4" vertical={false} />
|
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
|
<YAxis
|
|
tick={{ fontSize: 11, fill: "#94a3b8" }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tickFormatter={(v) => `${symbol}${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
borderRadius: 12,
|
|
border: "1px solid #eef0f4",
|
|
boxShadow: "0 8px 24px -8px rgba(0,0,0,0.15)",
|
|
fontSize: 12,
|
|
}}
|
|
formatter={(v: any) => [formatCurrency(Number(v), symbol), "Ingresos"]}
|
|
labelStyle={{ fontWeight: 700, color: "#0f172a" }}
|
|
/>
|
|
<Area type="monotone" dataKey="revenue" stroke="#3b66ff" strokeWidth={2.5} fill="url(#rev)" />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card p-5">
|
|
<div className="mb-4">
|
|
<h3 className="text-sm font-bold text-slate-900">Por categoría</h3>
|
|
<p className="text-xs text-slate-500">Distribución de ingresos</p>
|
|
</div>
|
|
{!cats ? (
|
|
<div className="flex h-64 items-center justify-center"><Spinner /></div>
|
|
) : cats.slices.length === 0 ? (
|
|
<EmptyState title="Sin datos" />
|
|
) : (
|
|
<div className="flex flex-col items-center">
|
|
<ResponsiveContainer width="100%" height={180}>
|
|
<PieChart>
|
|
<Pie
|
|
data={cats.slices}
|
|
dataKey="revenue"
|
|
nameKey="category"
|
|
innerRadius={48}
|
|
outerRadius={72}
|
|
paddingAngle={2}
|
|
>
|
|
{cats.slices.map((_, i) => (
|
|
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{ borderRadius: 12, border: "1px solid #eef0f4", fontSize: 12 }}
|
|
formatter={(v: any, _n, p: any) => [
|
|
`${formatCurrency(Number(v), symbol)} (${Math.round((Number(v) / totalCat) * 100)}%)`,
|
|
p.payload.category,
|
|
]}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
<div className="mt-2 w-full space-y-1.5">
|
|
{cats.slices.slice(0, 5).map((s, i) => (
|
|
<div key={s.category} className="flex items-center gap-2 text-xs">
|
|
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: PIE_COLORS[i % PIE_COLORS.length] }} />
|
|
<span className="flex-1 font-medium text-slate-600">{s.category}</span>
|
|
<span className="font-bold text-slate-800">{formatCurrency(s.revenue, symbol)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Best employees + best services */}
|
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
<RankCard
|
|
title="Mejores empleados"
|
|
subtitle="Por ingresos generados"
|
|
icon={Crown}
|
|
iconColor="#f59e0b"
|
|
items={(emp?.employees ?? []).slice(0, 5).map((r, i) => ({
|
|
id: r.employee.id,
|
|
rank: i + 1,
|
|
title: r.employee.name,
|
|
subtitle: r.employee.role,
|
|
value: formatCurrency(r.revenue, symbol),
|
|
extra: `${r.appointments} citas · ⭐ ${r.avg_rating}`,
|
|
color: r.employee.color,
|
|
}))}
|
|
/>
|
|
<RankCard
|
|
title="Servicios más rentables"
|
|
subtitle="Por ingresos generados"
|
|
icon={Scissors}
|
|
iconColor="#3b66ff"
|
|
barMode
|
|
items={(svc?.services ?? []).slice(0, 5).map((r, i) => ({
|
|
id: r.service.id,
|
|
rank: i + 1,
|
|
title: r.service.name,
|
|
subtitle: r.service.category,
|
|
value: formatCurrency(r.revenue, symbol),
|
|
extra: `${r.count} ventas`,
|
|
color: r.service.color,
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Top tickets */}
|
|
<div className="card p-5">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<TicketIcon className="h-4 w-4 text-accent-600" />
|
|
<h3 className="text-sm font-bold text-slate-900">Tickets más altos</h3>
|
|
</div>
|
|
<span className="text-xs text-slate-500">últimos {range} días</span>
|
|
</div>
|
|
{!tickets ? (
|
|
<div className="flex h-24 items-center justify-center"><Spinner /></div>
|
|
) : tickets.tickets.length === 0 ? (
|
|
<EmptyState title="Sin tickets" />
|
|
) : (
|
|
<>
|
|
{/* Mobile cards */}
|
|
<div className="divide-y divide-slate-50 sm:hidden">
|
|
{tickets.tickets.map(({ ticket }) => (
|
|
<div key={ticket.id} className="flex items-center gap-2 py-2">
|
|
<Avatar name={ticket.client?.name ?? "?"} size={30} color="#94a3b8" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-bold text-slate-800">{ticket.client?.name}</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="chip" style={{ background: `${ticket.service?.color}1a`, color: ticket.service?.color }}>
|
|
{ticket.service?.name}
|
|
</span>
|
|
</div>
|
|
<div className="text-[11px] text-slate-400">{ticket.employee?.name} · {paymentLabel(ticket.payment_method)}</div>
|
|
</div>
|
|
<div className="shrink-0 text-right font-bold text-slate-900">
|
|
{formatCurrency(ticket.amount + ticket.tip, symbol)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Desktop table */}
|
|
<div className="hidden overflow-x-auto sm:block">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-[11px] font-bold uppercase tracking-wider text-slate-400">
|
|
<th className="px-2 py-1.5">Cliente</th>
|
|
<th className="px-2 py-1.5">Servicio</th>
|
|
<th className="px-2 py-1.5">Especialista</th>
|
|
<th className="px-2 py-1.5">Pago</th>
|
|
<th className="px-2 py-1.5 text-right">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tickets.tickets.map(({ ticket }) => (
|
|
<tr key={ticket.id} className="border-t border-slate-50">
|
|
<td className="px-2 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar name={ticket.client?.name ?? "?"} size={26} color="#94a3b8" />
|
|
<span className="font-semibold text-slate-800">{ticket.client?.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-2 py-2">
|
|
<span className="chip" style={{ background: `${ticket.service?.color}1a`, color: ticket.service?.color }}>
|
|
{ticket.service?.name}
|
|
</span>
|
|
</td>
|
|
<td className="px-2 py-2 text-slate-600">{ticket.employee?.name}</td>
|
|
<td className="px-2 py-2 text-xs text-slate-500">{paymentLabel(ticket.payment_method)}</td>
|
|
<td className="px-2 py-2 text-right font-bold text-slate-900">
|
|
{formatCurrency(ticket.amount + ticket.tip, symbol)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top + frequent clients */}
|
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
<RankCard
|
|
title="Top clientes"
|
|
subtitle="Por gasto total"
|
|
icon={Trophy}
|
|
iconColor="#a855f7"
|
|
items={(topClients?.clients ?? []).slice(0, 6).map((r, i) => ({
|
|
id: r.client.id,
|
|
rank: i + 1,
|
|
title: r.client.name,
|
|
subtitle: r.last_visit ? `Última visita ${formatDate(r.last_visit)}` : "—",
|
|
value: formatCurrency(r.total_spent, symbol),
|
|
extra: `${r.visits} visitas`,
|
|
tags: r.client.tags,
|
|
}))}
|
|
/>
|
|
<RankCard
|
|
title="Clientes frecuentes"
|
|
subtitle="Por número de visitas"
|
|
icon={Flame}
|
|
iconColor="#f17616"
|
|
items={(freqClients?.clients ?? []).slice(0, 6).map((r, i) => ({
|
|
id: r.client.id,
|
|
rank: i + 1,
|
|
title: r.client.name,
|
|
subtitle: r.last_visit ? `Última visita ${formatDate(r.last_visit)}` : "—",
|
|
value: `${r.visits} visitas`,
|
|
extra: formatCurrency(r.total_spent, symbol),
|
|
tags: r.client.tags,
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Commissions */}
|
|
<div className="card overflow-hidden">
|
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<Coins className="h-4 w-4 text-emerald-600" />
|
|
<h3 className="text-sm font-bold text-slate-900">Comisiones del equipo</h3>
|
|
</div>
|
|
<span className="text-xs text-slate-500">últimos {range} días</span>
|
|
</div>
|
|
{!commissions ? (
|
|
<div className="flex h-24 items-center justify-center"><Spinner /></div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-2 p-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div className="rounded-xl bg-emerald-50 p-3">
|
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-emerald-700">Total comisiones</div>
|
|
<div className="text-xl font-extrabold text-emerald-700">{formatCurrency(commissions.total, symbol)}</div>
|
|
</div>
|
|
{(commissions.rows ?? []).slice(0, 6).map((r: any) => (
|
|
<div key={r.employee.id} className="flex items-center gap-2 rounded-xl bg-slate-50/70 px-3 py-2">
|
|
<Avatar name={r.employee.name} color={r.employee.color} size={32} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-bold text-slate-800">{r.employee.name}</div>
|
|
<div className="text-[11px] text-slate-500">{r.sales} ventas · {formatCurrency(r.revenue, symbol)}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm font-extrabold text-emerald-600">{formatCurrency(r.commission, symbol)}</div>
|
|
<div className="text-[10px] text-slate-400">comisión</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KpiCard({
|
|
title,
|
|
value,
|
|
sub,
|
|
delta,
|
|
deltaSuffix,
|
|
icon: Icon,
|
|
color,
|
|
}: {
|
|
title: string;
|
|
value: string;
|
|
sub?: string;
|
|
delta?: number;
|
|
deltaSuffix?: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
color: string;
|
|
}) {
|
|
const positive = (delta ?? 0) >= 0;
|
|
return (
|
|
<div className="card relative overflow-hidden p-5">
|
|
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full opacity-10" style={{ background: color }} />
|
|
<div className="flex items-start justify-between">
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">{title}</div>
|
|
<div className="mt-1 text-2xl font-extrabold tracking-tight text-slate-900">{value}</div>
|
|
{sub && <div className="mt-0.5 text-xs text-slate-500">{sub}</div>}
|
|
</div>
|
|
<div
|
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-white shadow-soft"
|
|
style={{ background: color }}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
{delta !== undefined && (
|
|
<div
|
|
className={cn(
|
|
"mt-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-bold",
|
|
positive ? "bg-emerald-50 text-emerald-700" : "bg-rose-50 text-rose-700"
|
|
)}
|
|
>
|
|
{positive ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
|
{positive ? "+" : ""}{delta}%
|
|
<span className="font-medium opacity-70">{deltaSuffix}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RankItem {
|
|
id: number;
|
|
rank: number;
|
|
title: string;
|
|
subtitle?: string;
|
|
value: string;
|
|
extra?: string;
|
|
color?: string;
|
|
tags?: string | null;
|
|
}
|
|
|
|
function RankCard({
|
|
title,
|
|
subtitle,
|
|
icon: Icon,
|
|
iconColor,
|
|
items,
|
|
barMode,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
iconColor: string;
|
|
items: RankItem[];
|
|
barMode?: boolean;
|
|
}) {
|
|
const maxVal = Math.max(...items.map((i) => parseFloat(i.value.replace(/[^0-9.]/g, "")) || 0), 1);
|
|
return (
|
|
<div className="card p-5">
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<div
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-white"
|
|
style={{ background: iconColor }}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
|
|
{subtitle && <p className="text-xs text-slate-500">{subtitle}</p>}
|
|
</div>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<EmptyState title="Sin datos" />
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
{items.map((it) => (
|
|
<div key={it.id} className="flex items-center gap-3">
|
|
<div
|
|
className={cn(
|
|
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
|
|
it.rank === 1
|
|
? "bg-amber-100 text-amber-700"
|
|
: it.rank === 2
|
|
? "bg-slate-200 text-slate-600"
|
|
: it.rank === 3
|
|
? "bg-orange-100 text-orange-700"
|
|
: "bg-slate-100 text-slate-500"
|
|
)}
|
|
>
|
|
{it.rank === 1 ? <Star className="h-3.5 w-3.5 fill-current" /> : it.rank}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
{it.color && <span className="h-2 w-2 shrink-0 rounded-full" style={{ background: it.color }} />}
|
|
<span className="truncate text-sm font-bold text-slate-800">{it.title}</span>
|
|
{it.tags?.split(",")[0] && (
|
|
<span className="chip bg-slate-100 text-slate-500">{it.tags.split(",")[0]}</span>
|
|
)}
|
|
</div>
|
|
<span className="shrink-0 text-sm font-bold text-slate-900">{it.value}</span>
|
|
</div>
|
|
<div className="mt-0.5 flex items-center justify-between gap-2">
|
|
{it.subtitle && <span className="truncate text-[11px] text-slate-500">{it.subtitle}</span>}
|
|
{it.extra && <span className="shrink-0 text-[11px] font-medium text-slate-400">{it.extra}</span>}
|
|
</div>
|
|
{barMode && (
|
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-slate-100">
|
|
<div
|
|
className="h-full rounded-full"
|
|
style={{
|
|
width: `${(parseFloat(it.value.replace(/[^0-9.]/g, "")) / maxVal) * 100}%`,
|
|
background: it.color ?? iconColor,
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|