Files
AgendaPro/src/pages/DashboardPage.tsx
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
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]>
2026-07-28 14:25:58 -06:00

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