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:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { Search, Users, Mail, Phone, DollarSign, Calendar, ChevronRight, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { formatCurrency, formatDate, formatRelative } from "../lib/format";
export function ClientsPage() {
const navigate = useNavigate();
const [q, setQ] = useState("");
const [debounced, setDebounced] = useState("");
const [timer, setTimer] = useState<any>(null);
const onChange = (v: string) => {
setQ(v);
if (timer) clearTimeout(timer);
const t = setTimeout(() => setDebounced(v), 250);
setTimer(t);
};
const { data, isFetching } = useQuery({
queryKey: ["clients", debounced],
queryFn: () => api.clients.list(debounced),
});
return (
<div className="flex h-full flex-col">
<PageHeader
title="Clientes"
subtitle="Busca, edita y revisa el historial de tus clientes."
/>
<div className="px-5 pt-4 sm:px-7">
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
className="input pl-9"
placeholder="Buscar por nombre, teléfono o correo…"
value={q}
onChange={(e) => onChange(e.target.value)}
/>
{isFetching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
</div>
</div>
<div className="flex-1 px-5 py-5 sm:px-7">
{isFetching && !data ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data?.clients.length ? (
<EmptyState icon={Users} title="Sin clientes" description="Los clientes aparecerán aquí cuando agendes su primera cita." />
) : (
<div className="card overflow-hidden">
{/* Mobile card list */}
<div className="divide-y divide-slate-50 sm:hidden">
{data.clients.map((c) => (
<button
key={c.id}
onClick={() => navigate(`/clients/${c.id}`)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-50/60"
>
<Avatar name={c.name} color="#94a3b8" size={38} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-bold text-slate-900">{c.name}</span>
{c.tags?.split(",")[0] && (
<span className="chip bg-brand-50 text-brand-600">{c.tags.split(",")[0].trim()}</span>
)}
</div>
<div className="truncate text-[11px] text-slate-500">
{c.phone ?? c.email ?? "Sin contacto"}
</div>
<div className="mt-0.5 flex items-center gap-3 text-[11px]">
<span className="font-semibold text-slate-600">{c.stats?.visits ?? 0} visitas</span>
<span className="font-bold text-emerald-600">{formatCurrency(c.stats?.total_spent ?? 0)}</span>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-slate-300" />
</button>
))}
</div>
{/* Desktop table */}
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-left text-[11px] font-bold uppercase tracking-wider text-slate-400">
<th className="px-4 py-3">Cliente</th>
<th className="px-4 py-3">Contacto</th>
<th className="px-4 py-3 text-right">Visitas</th>
<th className="px-4 py-3 text-right">Gasto total</th>
<th className="px-4 py-3">Última visita</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{data.clients.map((c) => (
<tr
key={c.id}
onClick={() => navigate(`/clients/${c.id}`)}
className="group cursor-pointer border-b border-slate-50 transition-colors hover:bg-slate-50/60"
>
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
<Avatar name={c.name} color="#94a3b8" size={34} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-slate-900">{c.name}</span>
{c.tags?.split(",").map((t) => (
<span key={t} className="chip bg-brand-50 text-brand-600">{t.trim()}</span>
))}
</div>
<span className="text-[11px] text-slate-400">Desde {formatDate(c.created_at)}</span>
</div>
</div>
</td>
<td className="px-4 py-3 text-slate-600">
<div className="flex flex-col gap-0.5 text-xs">
{c.phone && <span className="inline-flex items-center gap-1"><Phone className="h-3 w-3" /> {c.phone}</span>}
{c.email && <span className="inline-flex items-center gap-1"><Mail className="h-3 w-3" /> {c.email}</span>}
</div>
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center gap-1 font-bold text-slate-800">
<Calendar className="h-3.5 w-3.5 text-slate-400" />
{c.stats?.visits ?? 0}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center gap-1 font-bold text-emerald-600">
<DollarSign className="h-3.5 w-3.5" />
{formatCurrency(c.stats?.total_spent ?? 0)}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-500">{formatRelative(c.stats?.last_visit ?? null)}</td>
<td className="px-4 py-3 text-right">
<ChevronRight className="h-4 w-4 text-slate-300 transition-transform group-hover:translate-x-0.5 group-hover:text-brand-500" />
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-center gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-2.5 text-xs text-slate-500">
<Sparkles className="h-3.5 w-3.5 text-brand-400" />
Mostrando {data.clients.length} cliente{data.clients.length !== 1 ? "s" : ""}
</div>
</div>
)}
</div>
</div>
);
}