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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user