AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK.
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
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,
|
||||
} 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"],
|
||||
queryFn: () => api.dashboard.overview(),
|
||||
});
|
||||
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(
|
||||
"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-4">
|
||||
<KpiCard
|
||||
title="Ingresos del período"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.revenue_month ?? 0, symbol)}
|
||||
sub="últimos 30 días"
|
||||
delta={overview?.revenue_change_pct}
|
||||
deltaSuffix="% vs mes anterior"
|
||||
icon={DollarSign}
|
||||
color="#3b66ff"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Citas programadas"
|
||||
value={ovLoading ? "…" : formatNumber(overview?.appts_upcoming ?? 0)}
|
||||
sub="próximas"
|
||||
icon={CalendarCheck}
|
||||
color="#10b981"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ticket promedio"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.avg_ticket ?? 0, symbol)}
|
||||
sub="por venta"
|
||||
icon={TrendingUp}
|
||||
color="#a855f7"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ocupación"
|
||||
value={ovLoading ? "…" : `${overview?.occupancy_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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user