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
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useMemo, useRef, useState } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import listPlugin from "@fullcalendar/list";
import type {
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import esLocale from "@fullcalendar/core/locales/es";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Plus, Filter, RotateCcw } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment } from "../../shared/types";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { AppointmentModal } from "../components/AppointmentModal";
import { statusColor } from "../lib/format";
const STATUS_FILTERS = [
{ value: "all", label: "Todas" },
{ value: "scheduled", label: "Programadas" },
{ value: "completed", label: "Completadas" },
{ value: "cancelled", label: "Canceladas" },
];
export function CalendarPage() {
const { user } = useAuth();
const qc = useQueryClient();
const calRef = useRef<FullCalendar>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Appointment | null>(null);
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
const [employeeFilter, setEmployeeFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [isMobile, setIsMobile] = useState(
typeof window !== "undefined" ? window.matchMedia("(max-width: 640px)").matches : false
);
useEffect(() => {
const mq = window.matchMedia("(max-width: 640px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const isOwner = user?.role === "owner";
// Employees see only their own by default
const effectiveEmployee = !isOwner && user?.employee_id ? user.employee_id : employeeFilter;
const { data: employeesData } = useQuery({
queryKey: ["employees"],
queryFn: () => api.employees.list(),
enabled: isOwner,
});
const rangeRef = useRef<{ from: string; to: string }>({ from: "", to: "" });
const { data, isLoading, refetch } = useQuery({
queryKey: ["appointments", "calendar", effectiveEmployee, statusFilter],
queryFn: async () => {
const cal = calRef.current?.getApi();
const from = cal?.view.activeStart.toISOString() ?? new Date().toISOString();
const to = cal?.view.activeEnd.toISOString() ?? new Date().toISOString();
rangeRef.current = { from, to };
return api.appointments.list({
from,
to,
employee_id: effectiveEmployee === "all" ? undefined : Number(effectiveEmployee),
status: statusFilter === "all" ? undefined : statusFilter,
});
},
});
const events = useMemo(() => {
return (data?.appointments ?? []).map((a) => {
const empColor = a.employee?.color ?? "#3b66ff";
const bg =
a.status === "cancelled"
? "#f3f4f6"
: a.status === "completed"
? `${empColor}22`
: `${empColor}1a`;
const border = a.status === "cancelled" ? "#d1d5db" : empColor;
const fg = a.status === "cancelled" ? "#6b7280" : a.status === "completed" ? empColor : "#1e293b";
return {
id: String(a.id),
start: a.start_at,
end: a.end_at,
title: `${a.client?.name ?? "Cliente"} · ${a.service?.name ?? ""}`,
backgroundColor: bg,
borderColor: border,
textColor: fg,
extendedProps: { appointment: a },
editable: a.status !== "completed",
};
});
}, [data]);
const moveMutation = useMutation({
mutationFn: async ({ id, start, end }: { id: number; start: string; end: string }) =>
api.appointments.update(id, { start_at: start, end_at: end }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["appointments"] }),
});
const onDrop = (arg: EventDropArg) => {
const id = Number(arg.event.id);
const start = arg.event.start!.toISOString();
const end = (arg.event.end ?? new Date(arg.event.start!.getTime() + 60 * 60000)).toISOString();
moveMutation.mutate({ id, start, end });
};
const onResize = (arg: any) => {
const id = Number(arg.event.id);
moveMutation.mutate({
id,
start: arg.event.start!.toISOString(),
end: arg.event.end!.toISOString(),
});
};
const onSelectSlot = (arg: DateSelectArg) => {
setEditing(null);
setDraftSlot({ start: arg.start.toISOString(), end: arg.end.toISOString() });
setModalOpen(true);
};
const onClickEvent = (arg: EventClickArg) => {
arg.jsEvent.preventDefault();
const a = arg.event.extendedProps.appointment as Appointment;
setEditing(a);
setDraftSlot(null);
setModalOpen(true);
};
const onToday = () => calRef.current?.getApi().today();
return (
<div className="flex h-full flex-col">
<PageHeader
title="Calendario"
subtitle={
isOwner
? "Arrastra para reagendar, redimensiona para cambiar duración, clic para editar."
: "Tu agenda personal. Crea citas y se te asignarán automáticamente."
}
actions={
<>
<button onClick={() => refetch()} className="btn btn-secondary" title="Refrescar">
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refrescar</span>
</button>
<button
onClick={() => {
setEditing(null);
setDraftSlot({ start: new Date().toISOString(), end: new Date(Date.now() + 60 * 60000).toISOString() });
setModalOpen(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" />
Nueva cita
</button>
</>
}
/>
<div className="flex flex-wrap items-center gap-3 px-5 pb-3 pt-4 sm:px-7">
{isOwner && (
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
<Filter className="h-4 w-4 shrink-0 text-slate-400" />
<select
className="select w-full min-w-0 sm:w-auto sm:min-w-[180px]"
value={employeeFilter === "all" ? "all" : String(employeeFilter)}
onChange={(e) => setEmployeeFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
>
<option value="all">Todos los empleados</option>
{employeesData?.employees.map((e) => (
<option key={e.id} value={e.id}>
{e.name} {e.role}
</option>
))}
</select>
</div>
)}
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{STATUS_FILTERS.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors ${
statusFilter === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
{s.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-6 sm:px-7">
<div className="card overflow-x-auto p-3 sm:p-4">
{isLoading && !data && (
<div className="flex h-64 items-center justify-center">
<Spinner className="h-6 w-6 text-brand-500" />
</div>
)}
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
locale={esLocale}
initialView={isMobile ? "timeGridDay" : "timeGridWeek"}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "timeGridDay,listWeek" }
: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }
}
footerToolbar={isMobile ? { center: "today" } : false}
buttonText={{
today: "Hoy",
month: "Mes",
week: "Semana",
day: "Día",
list: "Lista",
}}
views={{
listWeek: {
buttonText: "Lista",
listDayFormat: { weekday: "long", day: "numeric", month: "short" },
listDaySideFormat: false,
noEventsContent: "Sin citas esta semana",
},
timeGridDay: { buttonText: "Día" },
}}
height={isMobile ? "auto" : "auto"}
contentHeight={isMobile ? 560 : 680}
stickyHeaderDates
firstDay={1}
slotMinTime="08:00:00"
slotMaxTime="21:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
nowIndicator
allDaySlot={false}
selectable
selectMirror
editable
eventResizableFromStart
eventDurationEditable
eventStartEditable
dayMaxEvents={isMobile ? 2 : 3}
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
events={events}
select={onSelectSlot}
eventClick={onClickEvent}
eventDrop={onDrop}
eventResize={onResize}
datesSet={() => refetch()}
eventContent={(arg) => <EventContent arg={arg} />}
/>
{!isLoading && events.length === 0 && (
<div className="mt-2">
<EmptyState
icon={CalendarDays}
title="Sin citas en este rango"
description="Selecciona un horario en el calendario o crea una nueva cita."
/>
</div>
)}
</div>
<button onClick={onToday} className="sr-only">Hoy</button>
</div>
<AppointmentModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditing(null);
setDraftSlot(null);
}}
appointment={editing}
draftSlot={draftSlot}
/>
</div>
);
}
function EventContent({ arg }: { arg: any }) {
const a = arg.event.extendedProps.appointment as Appointment | undefined;
if (!a) return <div>{arg.timeText} {arg.event.title}</div>;
const c = statusColor(a.status);
const isTime = arg.view.type !== "dayGridMonth";
return (
<div className="flex h-full flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: c.dot }} />
<span className="text-[10px] font-semibold opacity-70">{arg.timeText}</span>
</div>
<div className="truncate font-bold leading-tight">{a.client?.name}</div>
<div className="truncate text-[10px] opacity-80">{a.service?.name}</div>
{isTime && a.employee && (
<div className="mt-auto truncate text-[10px] opacity-70">con {a.employee.name}</div>
)}
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Wallet,
ArrowDownCircle,
ArrowUpCircle,
Lock,
Unlock,
Trash2,
Plus,
TrendingUp,
TrendingDown,
CircleDollarSign,
} from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, formatTime, formatDate, paymentLabel, cn } from "../lib/format";
export function CashPage() {
const { business } = useAuth();
const qc = useQueryClient();
const sym = business?.currency_symbol ?? "$";
const { data, isLoading, refetch } = useQuery({ queryKey: ["cash", "session"], queryFn: () => api.cash.session() });
const session = data?.session;
const stats = data?.stats;
const { data: entriesData } = useQuery({
queryKey: ["cash", "entries", session?.id],
queryFn: () => api.cash.entries(session?.id),
enabled: !!session,
});
const [openModal, setOpenModal] = useState(false);
const [closeModal, setCloseModal] = useState(false);
const [entryModal, setEntryModal] = useState<"income" | "expense" | null>(null);
const openMut = useMutation({
mutationFn: (d: any) => api.cash.open(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["cash"] }); setOpenModal(false); },
});
const closeMut = useMutation({
mutationFn: (d: any) => api.cash.close(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["cash"] }); setCloseModal(false); },
});
const addEntryMut = useMutation({
mutationFn: (d: any) => api.cash.addEntry(d),
onSuccess: () => qc.invalidateQueries({ queryKey: ["cash"] }),
});
const removeEntryMut = useMutation({
mutationFn: (id: number) => api.cash.removeEntry(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["cash"] }),
});
if (isLoading) {
return <div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>;
}
if (!session) {
return (
<div className="flex h-full flex-col">
<PageHeader title="Caja" subtitle="Apertura y cierre de caja diario." />
<div className="flex flex-1 items-center justify-center px-5">
<div className="card max-w-md p-8 text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Lock className="h-7 w-7" />
</div>
<h3 className="text-lg font-extrabold text-slate-900">La caja está cerrada</h3>
<p className="mt-1 text-sm text-slate-500">Abre la caja para registrar ingresos, egresos y el cierre del día.</p>
<button onClick={() => setOpenModal(true)} className="btn btn-primary mx-auto mt-5">
<Unlock className="h-4 w-4" /> Abrir caja
</button>
</div>
</div>
<OpenModal open={openModal} onClose={() => setOpenModal(false)} onOpen={(d: any) => openMut.mutate(d)} pending={openMut.isPending} sym={sym} />
</div>
);
}
const expected = Number(session.opening_amount) + (stats?.income ?? 0) - (stats?.expense ?? 0);
const entries = entriesData?.entries ?? [];
return (
<div className="flex h-full flex-col">
<PageHeader
title="Caja"
subtitle={`Abierta ${formatDate(session.opened_at)} · ${formatTime(session.opened_at)}`}
actions={
<>
<button onClick={() => setEntryModal("income")} className="btn btn-secondary"><Plus className="h-4 w-4" /> Ingreso</button>
<button onClick={() => setEntryModal("expense")} className="btn btn-secondary"><ArrowUpCircle className="h-4 w-4 text-rose-500" /> Egreso</button>
<button onClick={() => setCloseModal(true)} className="btn btn-primary"><Lock className="h-4 w-4" /> Cerrar caja</button>
</>
}
/>
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Kpi icon={CircleDollarSign} color="#3b66ff" label="Apertura" value={formatCurrency(session.opening_amount, sym)} />
<Kpi icon={TrendingUp} color="#10b981" label="Ingresos" value={formatCurrency(stats?.income ?? 0, sym)} />
<Kpi icon={TrendingDown} color="#ef4444" label="Egresos" value={formatCurrency(stats?.expense ?? 0, sym)} />
<Kpi icon={Wallet} color="#a855f7" label="Esperado en caja" value={formatCurrency(expected, sym)} />
</div>
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h3 className="text-sm font-bold text-slate-900">Movimientos del día ({entries.length})</h3>
<button onClick={() => refetch()} className="text-xs font-semibold text-brand-600 hover:underline">Refrescar</button>
</div>
{!entries.length ? (
<EmptyState icon={Wallet} title="Sin movimientos" description="Registra un ingreso o egreso, o completa citas para verlas aquí." />
) : (
<div className="divide-y divide-slate-50">
{entries.map((e: any) => {
const income = e.type === "income";
return (
<div key={e.id} className="group flex items-center gap-3 px-5 py-2.5">
<div className={cn("flex h-8 w-8 items-center justify-center rounded-lg", income ? "bg-emerald-50 text-emerald-600" : "bg-rose-50 text-rose-600")}>
{income ? <ArrowDownCircle className="h-4 w-4" /> : <ArrowUpCircle className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{e.concept}</div>
<div className="text-[11px] text-slate-500">
{formatTime(e.created_at)} {e.payment_method ? `· ${paymentLabel(e.payment_method)}` : ""} {e.ticket_id ? "· ticket" : ""}
</div>
</div>
<div className={cn("text-sm font-extrabold", income ? "text-emerald-600" : "text-rose-600")}>
{income ? "+" : ""}{formatCurrency(e.amount, sym)}
</div>
{!e.ticket_id && (
<button
onClick={() => removeEntryMut.mutate(e.id)}
className="rounded-lg p-1.5 text-slate-300 opacity-0 transition-opacity hover:bg-rose-50 hover:text-rose-500 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
<EntryModal kind={entryModal} onClose={() => setEntryModal(null)} onSave={(d: any) => addEntryMut.mutate(d)} sym={sym} />
<CloseModal open={closeModal} expected={expected} sym={sym} onClose={() => setCloseModal(false)} onCloseCaja={(d: any) => closeMut.mutate(d)} pending={closeMut.isPending} />
</div>
);
}
function Kpi({ icon: Icon, color, label, value }: any) {
return (
<div className="card relative overflow-hidden p-4">
<div className="absolute -right-4 -top-4 h-16 w-16 rounded-full opacity-10" style={{ background: color }} />
<Icon className="h-4 w-4" style={{ color }} />
<div className="mt-1 truncate text-lg font-extrabold text-slate-900">{value}</div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
function OpenModal({ open, onClose, onOpen, pending, sym }: any) {
const [amount, setAmount] = useState("0");
const [note, setNote] = useState("");
return (
<Modal open={open} onClose={onClose} title="Abrir caja" subtitle="Registra el efectivo inicial en caja." size="sm">
<div className="space-y-3">
<div>
<label className="label">Efectivo inicial</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">{sym}</span>
<input type="number" min={0} className="input pl-7" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
</div>
<div><label className="label">Nota (opcional)</label><input className="input" value={note} onChange={(e) => setNote(e.target.value)} /></div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => onOpen({ opening_amount: Number(amount) || 0, note })} className="btn btn-primary" disabled={pending}>
{pending ? <Spinner /> : <Unlock className="h-4 w-4" />} Abrir
</button>
</div>
</Modal>
);
}
function CloseModal({ open, expected, sym, onClose, onCloseCaja, pending }: any) {
const [counted, setCounted] = useState("");
const [note, setNote] = useState("");
const diff = (Number(counted) || 0) - expected;
return (
<Modal open={open} onClose={onClose} title="Cerrar caja" subtitle="Confirma el efectivo contado para el cierre." size="sm">
<div className="space-y-3">
<div className="rounded-xl bg-slate-50 p-3 text-sm">
<div className="flex justify-between"><span className="text-slate-500">Esperado en caja</span><span className="font-bold text-slate-800">{formatCurrency(expected, sym)}</span></div>
</div>
<div>
<label className="label">Efectivo contado</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">{sym}</span>
<input type="number" min={0} className="input pl-7" value={counted} onChange={(e) => setCounted(e.target.value)} placeholder={String(expected)} />
</div>
{counted !== "" && (
<div className={cn("mt-1 text-xs font-semibold", Math.abs(diff) < 0.01 ? "text-emerald-600" : "text-rose-600")}>
{Math.abs(diff) < 0.01 ? "✓ Cuadra perfecto" : diff > 0 ? `Sobrante de ${formatCurrency(diff, sym)}` : `Faltante de ${formatCurrency(-diff, sym)}`}
</div>
)}
</div>
<div><label className="label">Nota (opcional)</label><input className="input" value={note} onChange={(e) => setNote(e.target.value)} /></div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => onCloseCaja({ closing_amount: Number(counted) || expected, note })} className="btn btn-primary" disabled={pending}>
{pending ? <Spinner /> : <Lock className="h-4 w-4" />} Cerrar caja
</button>
</div>
</Modal>
);
}
function EntryModal({ kind, onClose, onSave, sym }: any) {
const [amount, setAmount] = useState("");
const [concept, setConcept] = useState("");
const [method, setMethod] = useState("cash");
useEffect(() => { if (kind) { setAmount(""); setConcept(""); } }, [kind]);
if (!kind) return null;
const income = kind === "income";
return (
<Modal open={!!kind} onClose={onClose} title={income ? "Registrar ingreso" : "Registrar egreso"} size="sm">
<div className="space-y-3">
<div>
<label className="label">Concepto *</label>
<input className="input" value={concept} onChange={(e) => setConcept(e.target.value)} placeholder={income ? "Ej. Venta de producto" : "Ej. Compra de insumos"} />
</div>
<div>
<label className="label">Monto *</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">{sym}</span>
<input type="number" min={0} className="input pl-7" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
</div>
<div><label className="label">Método</label>
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
<option value="cash">Efectivo</option><option value="card">Tarjeta</option><option value="transfer">Transferencia</option>
</select>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button
onClick={() => concept && amount && onSave({ type: kind, amount: Number(amount), concept, payment_method: method }).then(onClose)}
className="btn btn-primary"
disabled={!concept || !amount}
>
Guardar
</button>
</div>
</Modal>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Mail,
Phone,
DollarSign,
Calendar,
Star,
Clock,
Save,
Pencil,
} from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Avatar, Spinner, EmptyState, Badge } from "../components/ui";
import { Modal } from "../components/Modal";
import {
formatCurrency,
formatDate,
formatTime,
statusLabel,
statusColor,
} from "../lib/format";
export function ClientDetailPage() {
const { id } = useParams();
const clientId = Number(id);
const [editing, setEditing] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["clients", clientId],
queryFn: () => api.clients.list().then((r) => ({ ...r, target: r.clients.find((c) => c.id === clientId) })),
});
const client = data?.target;
const { data: appts } = useQuery({
queryKey: ["clients", clientId, "appts"],
queryFn: () => api.appointments.list({ client_id: clientId, limit: 50 }),
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>
);
}
if (!client) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState title="Cliente no encontrado" action={<Link to="/clients" className="btn btn-secondary">Volver</Link>} />
</div>
);
}
const stats = client.stats!;
return (
<div className="flex h-full flex-col">
<PageHeader
title={client.name}
subtitle="Perfil del cliente"
actions={
<>
<Link to="/clients" className="btn btn-ghost">
<ArrowLeft className="h-4 w-4" /> Volver
</Link>
<button onClick={() => setEditing(true)} className="btn btn-secondary">
<Pencil className="h-4 w-4" /> Editar
</button>
</>
}
/>
<div className="grid gap-5 px-5 py-5 sm:px-7 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-1">
<div className="card p-5">
<div className="flex items-center gap-3">
<Avatar name={client.name} color="#3b66ff" size={56} />
<div className="min-w-0">
<h3 className="truncate text-lg font-extrabold text-slate-900">{client.name}</h3>
{client.tags?.split(",").map((t) => (
<span key={t} className="chip mr-1 bg-brand-50 text-brand-600">{t.trim()}</span>
))}
</div>
</div>
<div className="mt-4 space-y-2 text-sm">
{client.phone && (
<a href={`tel:${client.phone}`} className="flex items-center gap-2 text-slate-600 hover:text-brand-600">
<Phone className="h-4 w-4 text-slate-400" /> {client.phone}
</a>
)}
{client.email && (
<a href={`mailto:${client.email}`} className="flex items-center gap-2 truncate text-slate-600 hover:text-brand-600">
<Mail className="h-4 w-4 text-slate-400 shrink-0" /> <span className="truncate">{client.email}</span>
</a>
)}
<div className="flex items-center gap-2 text-slate-500">
<Calendar className="h-4 w-4 text-slate-400" /> Desde {formatDate(client.created_at)}
</div>
</div>
{client.notes && (
<div className="mt-3 rounded-xl bg-slate-50 p-3 text-xs text-slate-600">
<span className="font-bold">Notas:</span> {client.notes}
</div>
)}
</div>
<div className="card p-5">
<h4 className="mb-3 text-xs font-bold uppercase tracking-wider text-slate-500">Resumen</h4>
<div className="grid grid-cols-2 gap-3">
<StatBox icon={DollarSign} color="#10b981" label="Gasto total" value={formatCurrency(stats.total_spent)} />
<StatBox icon={Calendar} color="#3b66ff" label="Visitas" value={String(stats.visits)} />
<StatBox icon={Clock} color="#a855f7" label="Ticket prom." value={formatCurrency(stats.avg_ticket)} />
<StatBox icon={Star} color="#f59e0b" label="No shows" value={String(stats.no_show_count)} />
</div>
</div>
</div>
<div className="card overflow-hidden lg:col-span-2">
<div className="border-b border-slate-100 px-5 py-4">
<h4 className="text-sm font-bold text-slate-900">Historial de citas</h4>
<p className="text-xs text-slate-500">Últimas {appts?.appointments.length ?? 0} citas</p>
</div>
{!appts?.appointments.length ? (
<EmptyState title="Sin historial" description="Este cliente aún no tiene citas." />
) : (
<div className="divide-y divide-slate-50">
{appts.appointments
.slice()
.sort((a, b) => b.start_at.localeCompare(a.start_at))
.map((a) => {
const c = statusColor(a.status);
return (
<div key={a.id} className="flex items-center gap-3 px-5 py-3">
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white"
style={{ background: a.service?.color ?? "#3b66ff" }}
>
<Calendar className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-slate-800">{a.service?.name}</span>
<Badge bg={c.bg} fg={c.fg} dot={c.dot}>
{statusLabel(a.status)}
</Badge>
</div>
<div className="text-xs text-slate-500">
{formatDate(a.start_at)} · {formatTime(a.start_at)} · con {a.employee?.name}
</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-slate-900">{formatCurrency(a.price)}</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
<ClientEditModal open={editing} client={client} onClose={() => setEditing(false)} />
</div>
);
}
function StatBox({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 p-3">
<Icon className="h-4 w-4" style={{ color }} />
<div className="mt-1 text-base font-extrabold text-slate-900">{value}</div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
function ClientEditModal({ open, client, onClose }: { open: boolean; client: any; onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState(client.name);
const [phone, setPhone] = useState(client.phone ?? "");
const [email, setEmail] = useState(client.email ?? "");
const [notes, setNotes] = useState(client.notes ?? "");
const [tags, setTags] = useState(client.tags ?? "");
const mut = useMutation({
mutationFn: () => api.clients.update(client.id, { name, phone, email, notes, tags }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["clients"] });
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title="Editar cliente"
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
{mut.isPending ? <Spinner /> : <Save className="h-4 w-4" />} Guardar
</button>
</div>
}
>
<div className="space-y-3">
<div>
<label className="label">Nombre *</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Teléfono</label>
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div>
<label className="label">Correo</label>
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
</div>
<div>
<label className="label">Etiquetas (separadas por coma)</label>
<input className="input" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="VIP, Frecuente" />
</div>
<div>
<label className="label">Notas</label>
<textarea className="textarea" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
</Modal>
);
}
+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>
);
}
+576
View File
@@ -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>
);
}
+313
View File
@@ -0,0 +1,313 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Star,
DollarSign,
CalendarCheck,
Activity,
Mail,
Phone,
Pencil,
Trash2,
} from "lucide-react";
import { api } from "../lib/api";
import type { Employee } from "../../shared/types";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, cn } from "../lib/format";
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
export function EmployeesPage() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() });
const { data: servicesData } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() });
const [editing, setEditing] = useState<Employee | null>(null);
const [creating, setCreating] = useState(false);
const removeMut = useMutation({
mutationFn: (id: number) => api.employees.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }),
});
return (
<div className="flex h-full flex-col">
<PageHeader
title="Empleados"
subtitle="Gestiona a tu equipo, sus servicios y mide su rendimiento."
actions={
<button
onClick={() => {
setEditing(null);
setCreating(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" /> Nuevo empleado
</button>
}
/>
<div className="flex-1 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data?.employees.length ? (
<EmptyState title="Sin empleados" description="Agrega a tu primer especialista." />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.employees.map((e) => (
<div key={e.id} className="card group p-5">
<div className="flex items-start gap-3">
<Avatar name={e.name} color={e.color} size={48} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-bold text-slate-900">{e.name}</h3>
{!e.active && (
<span className="chip bg-slate-100 text-slate-500">Inactivo</span>
)}
</div>
<p className="text-xs text-slate-500">{e.role}</p>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-500">
{e.email && (
<span className="inline-flex items-center gap-1">
<Mail className="h-3 w-3" /> {e.email}
</span>
)}
{e.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3" /> {e.phone}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => {
setEditing(e);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => {
if (confirm(`¿Eliminar a ${e.name}?`)) removeMut.mutate(e.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<Stat icon={DollarSign} color="#10b981" label="Ingresos" value={formatCurrency(e.stats?.revenue_total ?? 0)} />
<Stat icon={CalendarCheck} color="#3b66ff" label="Citas" value={String(e.stats?.appointments_completed ?? 0)} />
<Stat icon={Star} color="#f59e0b" label="Rating" value={`${e.stats?.avg_rating ?? e.rating}`} />
</div>
{e.service_ids && e.service_ids.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{(servicesData?.services ?? [])
.filter((s) => e.service_ids!.includes(s.id))
.slice(0, 4)
.map((s) => (
<span
key={s.id}
className="chip"
style={{ background: `${s.color}1a`, color: s.color }}
>
{s.name}
</span>
))}
{e.service_ids.length > 4 && (
<span className="chip bg-slate-100 text-slate-500">+{e.service_ids.length - 4}</span>
)}
</div>
)}
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-400">
<Activity className="h-3 w-3" />
Utilización {e.stats?.utilization_pct ?? 0}%
<div className="ml-1 h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.utilization_pct ?? 0}%` }} />
</div>
</div>
</div>
))}
</div>
)}
</div>
<EmployeeModal
open={creating || !!editing}
employee={editing}
services={servicesData?.services ?? []}
onClose={() => {
setEditing(null);
setCreating(false);
}}
/>
</div>
);
}
function Stat({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 px-2 py-2">
<Icon className="mx-auto h-3.5 w-3.5" style={{ color }} />
<div className="mt-1 truncate text-sm font-bold text-slate-800">{value}</div>
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
function EmployeeModal({
open,
employee,
services,
onClose,
}: {
open: boolean;
employee: Employee | null;
services: { id: number; name: string; color: string; category: string }[];
onClose: () => void;
}) {
const qc = useQueryClient();
const isEdit = !!employee;
const [name, setName] = useState("");
const [role, setRole] = useState("Especialista");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [active, setActive] = useState(true);
const [svcIds, setSvcIds] = useState<number[]>([]);
useEffect(() => {
if (!open) return;
if (employee) {
setName(employee.name);
setRole(employee.role);
setEmail(employee.email ?? "");
setPhone(employee.phone ?? "");
setColor(employee.color);
setActive(!!employee.active);
setSvcIds(employee.service_ids ?? []);
} else {
setName("");
setRole("Especialista");
setEmail("");
setPhone("");
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
setActive(true);
setSvcIds([]);
}
}, [open, employee]);
const mut = useMutation({
mutationFn: async () => {
const payload = { name, role, email, phone, color, active, service_ids: svcIds };
if (employee) return api.employees.update(employee.id, payload);
return api.employees.create(payload);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["employees"] });
qc.invalidateQueries({ queryKey: ["services"] });
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? "Editar empleado" : "Nuevo empleado"}
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
{mut.isPending ? <Spinner /> : isEdit ? "Guardar" : "Crear"}
</button>
</div>
}
>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Avatar name={name || "?"} color={color} size={56} />
<div className="flex-1">
<label className="label">Color</label>
<div className="flex flex-wrap gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn(
"h-7 w-7 rounded-full transition-transform",
color === c && "ring-2 ring-offset-2 ring-slate-400 scale-110"
)}
style={{ background: c }}
/>
))}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre *</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="label">Puesto</label>
<input className="input" value={role} onChange={(e) => setRole(e.target.value)} />
</div>
<div>
<label className="label">Correo</label>
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label className="label">Teléfono</label>
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
</div>
<div>
<label className="label">Servicios que ofrece</label>
<div className="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto rounded-xl border border-slate-100 p-2">
{services.map((s) => {
const on = svcIds.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => setSvcIds((arr) => (on ? arr.filter((x) => x !== s.id) : [...arr, s.id]))}
className={cn(
"chip transition-colors",
on ? "text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
)}
style={on ? { background: s.color } : undefined}
>
{s.name}
</button>
);
})}
</div>
</div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Activo (puede recibir citas)
</label>
</div>
</Modal>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, ArrowRight, Mail, Lock, AlertCircle, Wand2 } from "lucide-react";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
import { Spinner } from "../components/ui";
export function LoginPage() {
const { login, user } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("[email protected]");
const [password, setPassword] = useState("demo1234");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [demoUsers, setDemoUsers] = useState<{ email: string; name: string; role: string; avatar_color: string }[]>([]);
useEffect(() => {
if (user) navigate("/", { replace: true });
}, [user, navigate]);
useEffect(() => {
api.auth.demoUsers().then((r) => setDemoUsers(r.users)).catch(() => {});
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(email, password);
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
}
};
const quickLogin = async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="grid min-h-screen lg:grid-cols-2">
{/* Left: brand panel */}
<div className="relative hidden flex-col justify-between overflow-hidden bg-gradient-to-br from-brand-700 via-brand-600 to-brand-800 p-10 text-white lg:flex">
<div className="absolute -right-24 -top-24 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-96 w-96 rounded-full bg-accent-500/30 blur-3xl" />
<div className="relative z-10 flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/15 backdrop-blur">
<Sparkles className="h-5 w-5" />
</div>
<span className="text-lg font-extrabold tracking-tight">AgendaPro</span>
</div>
<div className="relative z-10 max-w-md">
<h1 className="text-4xl font-extrabold leading-tight tracking-tight">
La gestión visual de tu negocio, en un solo lugar.
</h1>
<p className="mt-4 text-brand-100">
Calendario de citas con arrastrar y soltar, control de empleados, tickets e ingresos en tiempo real.
Diseñado para dueños y equipos.
</p>
<ul className="mt-8 space-y-3 text-sm">
{[
"Agenda citas en segundos y arrástralas cuando quieras",
"Tablero con tus ingresos, mejores empleados y clientes top",
"Gestiona servicios, empleados y clientes desde un panel",
].map((t) => (
<li key={t} className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent-300" />
<span className="text-brand-50">{t}</span>
</li>
))}
</ul>
</div>
<div className="relative z-10 text-xs text-brand-200">
© {new Date().getFullYear()} AgendaPro · Demo
</div>
</div>
{/* Right: form */}
<div className="flex items-center justify-center bg-[#f6f7fb] px-5 py-10">
<div className="w-full max-w-sm">
<div className="mb-7 text-center lg:hidden">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-6 w-6" />
</div>
<h1 className="text-xl font-extrabold">AgendaPro</h1>
</div>
<div className="card p-6 shadow-card">
<h2 className="text-lg font-extrabold text-slate-900">Bienvenido de nuevo</h2>
<p className="mt-1 text-sm text-slate-500">Inicia sesión para acceder a tu panel.</p>
<form onSubmit={submit} className="mt-5 space-y-4">
<div>
<label className="label">Correo</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
className="input pl-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
autoComplete="email"
required
/>
</div>
</div>
<div>
<label className="label">Contraseña</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="password"
className="input pl-9"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
required
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-rose-50 px-3 py-2 text-sm font-medium text-rose-700">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={loading}>
{loading ? <Spinner /> : <>Entrar <ArrowRight className="h-4 w-4" /></>}
</button>
</form>
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
<span className="h-px flex-1 bg-slate-200" />
cuentas demo
<span className="h-px flex-1 bg-slate-200" />
</div>
<div className="mt-3 space-y-1.5">
{demoUsers.slice(0, 4).map((u) => (
<button
key={u.email}
onClick={() => quickLogin(u.email)}
disabled={loading}
className="group flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
>
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name.split(" ").map((p) => p[0]).slice(0, 2).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{u.name}</div>
<div className="truncate text-[11px] text-slate-500">
{u.role === "owner" ? "Dueño" : "Empleado"} · {u.email}
</div>
</div>
<Wand2 className="h-4 w-4 text-slate-300 transition-colors group-hover:text-brand-500" />
</button>
))}
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400">
Demo · cualquier cuenta usa la contraseña <code className="font-mono font-semibold text-slate-600">demo1234</code>
</p>
</div>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Bell,
MessageCircle,
Mail,
Send,
XCircle,
RefreshCw,
Clock,
CheckCircle2,
AlertTriangle,
} from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Spinner, EmptyState, Badge } from "../components/ui";
import { formatRelative, cn } from "../lib/format";
const CHANNEL = {
whatsapp: { icon: MessageCircle, color: "#10b981", label: "WhatsApp" },
sms: { icon: MessageCircle, color: "#3b66ff", label: "SMS" },
email: { icon: Mail, color: "#a855f7", label: "Email" },
};
const KIND_LABEL: Record<string, string> = {
confirmation: "Confirmación",
reminder: "Recordatorio",
cancellation: "Cancelación",
follow_up: "Seguimiento",
review: "Reseña",
};
const STATUS = [
{ value: "all", label: "Todas" },
{ value: "pending", label: "Pendientes" },
{ value: "sent", label: "Enviadas" },
];
export function NotificationsPage() {
const qc = useQueryClient();
const [status, setStatus] = useState("pending");
const { data: stats } = useQuery({ queryKey: ["notifications", "stats"], queryFn: () => api.notifications.stats() });
const { data } = useQuery({ queryKey: ["notifications", status], queryFn: () => api.notifications.list(status) });
const regen = useMutation({
mutationFn: () => api.notifications.regenerate(),
onSuccess: () => qc.invalidateQueries({ queryKey: ["notifications"] }),
});
const act = (id: number, kind: "send" | "cancel") =>
api.notifications[kind](id).then(() => qc.invalidateQueries({ queryKey: ["notifications"] }));
return (
<div className="flex h-full flex-col">
<PageHeader
title="Recordatorios"
subtitle="Reduce inasistencias con recordatorios automáticos por WhatsApp, SMS y email."
actions={
<button onClick={() => regen.mutate()} className="btn btn-secondary" disabled={regen.isPending}>
{regen.isPending ? <Spinner /> : <RefreshCw className="h-4 w-4" />} Regenerar
</button>
}
/>
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Kpi icon={Clock} color="#f17616" label="Pendientes" value={String(stats?.pending ?? 0)} />
<Kpi icon={CheckCircle2} color="#10b981" label="Enviadas" value={String(stats?.sent ?? 0)} />
<Kpi icon={Bell} color="#3b66ff" label="Recordatorios próximos" value={String(stats?.upcoming_reminders ?? 0)} />
<Kpi icon={AlertTriangle} color="#ef4444" label="Tasa no-show (30d)" value={`${stats?.no_show_rate ?? 0}%`} />
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{STATUS.map((s) => (
<button
key={s.value}
onClick={() => setStatus(s.value)}
className={cn("rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors", status === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100")}
>
{s.label}
</button>
))}
</div>
</div>
<div className="card overflow-hidden">
{!data ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data.notifications.length ? (
<EmptyState icon={Bell} title="Sin notificaciones" description="Regenera los recordatorios o agrega citas próximas." />
) : (
<div className="divide-y divide-slate-50">
{data.notifications.map((n: any) => {
const ch = CHANNEL[n.channel as keyof typeof CHANNEL] ?? CHANNEL.whatsapp;
const ChIcon = ch.icon;
return (
<div key={n.id} className="group flex items-start gap-3 px-5 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white" style={{ background: ch.color }}>
<ChIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-bold text-slate-900">{n.client_name ?? "Cliente"}</span>
<Badge bg="#eef4ff" fg="#1c36dc">{KIND_LABEL[n.kind] ?? n.kind}</Badge>
{n.service_name && <span className="text-[11px] text-slate-500">· {n.service_name}</span>}
{n.status === "pending" && <span className="chip bg-amber-50 text-amber-700">pendiente</span>}
{n.status === "sent" && <span className="chip bg-emerald-50 text-emerald-700"><CheckCircle2 className="h-3 w-3" /> enviada</span>}
{n.status === "canceled" && <span className="chip bg-slate-100 text-slate-500">cancelada</span>}
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-slate-500">{n.message}</p>
<div className="mt-0.5 text-[10px] text-slate-400">
Programa: {formatRelative(n.send_at)} · {ch.label}
</div>
</div>
{n.status === "pending" && (
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button onClick={() => act(n.id, "send")} className="rounded-lg p-1.5 text-slate-400 hover:bg-emerald-50 hover:text-emerald-600" title="Enviar ahora">
<Send className="h-3.5 w-3.5" />
</button>
<button onClick={() => act(n.id, "cancel")} className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600" title="Cancelar">
<XCircle className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
<p className="text-center text-[11px] text-slate-400">
Demo: el envío es simulado (no se conecta a WhatsApp/SMS reales). En producción se integraría con la API de WhatsApp Business / Twilio.
</p>
</div>
</div>
);
}
function Kpi({ icon: Icon, color, label, value }: any) {
return (
<div className="card relative overflow-hidden p-4">
<div className="absolute -right-4 -top-4 h-16 w-16 rounded-full opacity-10" style={{ background: color }} />
<Icon className="h-4 w-4" style={{ color }} />
<div className="mt-1 truncate text-lg font-extrabold text-slate-900">{value}</div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
+298
View File
@@ -0,0 +1,298 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Clock, DollarSign, Pencil, Trash2, Scissors } from "lucide-react";
import { api } from "../lib/api";
import type { Service } from "../../shared/types";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, cn } from "../lib/format";
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
export function ServicesPage() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() });
const { data: empData } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() });
const [editing, setEditing] = useState<Service | null>(null);
const [creating, setCreating] = useState(false);
const removeMut = useMutation({
mutationFn: (id: number) => api.services.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["services"] }),
});
const grouped: Record<string, Service[]> = {};
for (const s of data?.services ?? []) {
(grouped[s.category] ||= []).push(s);
}
const categories = Object.keys(grouped).sort();
return (
<div className="flex h-full flex-col">
<PageHeader
title="Servicios"
subtitle="El menú de servicios que tu negocio ofrece a los clientes."
actions={
<button
onClick={() => {
setEditing(null);
setCreating(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" /> Nuevo servicio
</button>
}
/>
<div className="flex-1 space-y-6 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data?.services.length ? (
<EmptyState
icon={Scissors}
title="Sin servicios"
description="Crea tu primer servicio del catálogo."
/>
) : (
categories.map((cat) => (
<div key={cat}>
<h3 className="mb-2 text-xs font-bold uppercase tracking-wider text-slate-500">{cat}</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{grouped[cat].map((s) => (
<div key={s.id} className="card group flex items-center gap-3 p-4">
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-white shadow-soft"
style={{ background: s.color }}
>
<Scissors className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="truncate font-bold text-slate-900">{s.name}</h4>
{!s.active && <span className="chip bg-slate-100 text-slate-500">Inactivo</span>}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-3 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" /> {s.duration_min} min
</span>
<span className="inline-flex items-center gap-1 font-bold text-slate-700">
<DollarSign className="h-3 w-3" /> {formatCurrency(s.price)}
</span>
</div>
{(s.employee_ids?.length ?? 0) > 0 && (
<div className="mt-1.5 text-[11px] text-slate-400">
{s.employee_ids!.length} especialista{s.employee_ids!.length > 1 ? "s" : ""}
</div>
)}
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => {
setEditing(s);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => {
if (confirm(`¿Eliminar "${s.name}"?`)) removeMut.mutate(s.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
<ServiceModal
open={creating || !!editing}
service={editing}
employees={empData?.employees ?? []}
onClose={() => {
setEditing(null);
setCreating(false);
}}
/>
</div>
);
}
function ServiceModal({
open,
service,
employees,
onClose,
}: {
open: boolean;
service: Service | null;
employees: { id: number; name: string; color: string }[];
onClose: () => void;
}) {
const qc = useQueryClient();
const isEdit = !!service;
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [description, setDescription] = useState("");
const [duration, setDuration] = useState(60);
const [price, setPrice] = useState(0);
const [color, setColor] = useState(COLORS[0]);
const [active, setActive] = useState(true);
const [empIds, setEmpIds] = useState<number[]>([]);
useEffect(() => {
if (!open) return;
if (service) {
setName(service.name);
setCategory(service.category);
setDescription(service.description ?? "");
setDuration(service.duration_min);
setPrice(service.price);
setColor(service.color);
setActive(!!service.active);
setEmpIds(service.employee_ids ?? []);
} else {
setName("");
setCategory("General");
setDescription("");
setDuration(60);
setPrice(0);
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
setActive(true);
setEmpIds([]);
}
}, [open, service]);
const mut = useMutation({
mutationFn: async () => {
const payload = {
name,
category,
description,
duration_min: duration,
price,
color,
active,
employee_ids: empIds,
};
if (service) return api.services.update(service.id, payload);
return api.services.create(payload);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["services"] });
qc.invalidateQueries({ queryKey: ["employees"] });
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? "Editar servicio" : "Nuevo servicio"}
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
{mut.isPending ? <Spinner /> : isEdit ? "Guardar" : "Crear"}
</button>
</div>
}
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre *</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ej. Corte de cabello" />
</div>
<div>
<label className="label">Categoría</label>
<input className="input" value={category} onChange={(e) => setCategory(e.target.value)} list="svc-categories" />
<datalist id="svc-categories">
<option value="Cabello" />
<option value="Barbería" />
<option value="Facial" />
<option value="Spa" />
<option value="Uñas" />
<option value="General" />
</datalist>
</div>
</div>
<div>
<label className="label">Descripción</label>
<textarea className="textarea" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Duración (minutos)</label>
<div className="flex flex-wrap gap-1.5">
{[15, 30, 45, 60, 75, 90, 120, 150, 180].map((d) => (
<button
key={d}
type="button"
onClick={() => setDuration(d)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs font-semibold transition-colors",
duration === d ? "border-brand-500 bg-brand-50 text-brand-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{d}
</button>
))}
</div>
</div>
<div>
<label className="label">Precio</label>
<input type="number" min={0} step={10} className="input" value={price} onChange={(e) => setPrice(Number(e.target.value))} />
</div>
</div>
<div>
<label className="label">Color</label>
<div className="flex flex-wrap gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn("h-7 w-7 rounded-full transition-transform", color === c && "ring-2 ring-offset-2 ring-slate-400 scale-110")}
style={{ background: c }}
/>
))}
</div>
</div>
<div>
<label className="label">Especialistas que lo ofrecen</label>
<div className="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto rounded-xl border border-slate-100 p-2">
{employees.length === 0 && <span className="px-2 py-1 text-xs text-slate-400">Sin empleados</span>}
{employees.map((e) => {
const on = empIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setEmpIds((arr) => (on ? arr.filter((x) => x !== e.id) : [...arr, e.id]))}
className={cn("chip transition-colors", on ? "text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200")}
style={on ? { background: e.color } : undefined}
>
{e.name}
</button>
);
})}
</div>
</div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Servicio activo (disponible para agendar)
</label>
</div>
</Modal>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Settings as SettingsIcon,
Save,
ShieldAlert,
CalendarClock,
Globe,
DollarSign,
Copy,
Check,
ExternalLink,
} from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Spinner } from "../components/ui";
export function SettingsPage() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: () => api.settings.get() });
const [f, setF] = useState<any>(null);
const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (data?.settings) setF(data.settings);
}, [data]);
const mut = useMutation({
mutationFn: () =>
api.settings.update({
name: f.name,
industry: f.industry,
phone: f.phone,
address: f.address,
slug: f.slug,
booking_enabled: f.booking_enabled ? 1 : 0,
cancel_window_hours: Number(f.cancel_window_hours),
cancel_penalty_pct: Number(f.cancel_penalty_pct),
require_deposit: f.require_deposit ? 1 : 0,
deposit_pct: Number(f.deposit_pct),
timezone: f.timezone,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["settings"] });
qc.invalidateQueries({ queryKey: ["business"] });
setSaved(true);
setTimeout(() => setSaved(false), 2500);
},
});
if (isLoading || !f) {
return (
<div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>
);
}
const set = (k: string, v: any) => setF((s: any) => ({ ...s, [k]: v }));
const bookingUrl = `${window.location.origin}/b/${f.slug}`;
return (
<div className="flex h-full flex-col">
<PageHeader title="Configuración" subtitle="Políticas del negocio, reservas online y cobranza." />
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
{/* Reservas online */}
<div className="card overflow-hidden">
<SectionTitle icon={Globe} color="#3b66ff" title="Reservas online" subtitle="Tu página pública para que los clientes agenden solos 24/7." />
<div className="space-y-4 p-5 pt-0">
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.booking_enabled} onChange={(e) => set("booking_enabled", e.target.checked)} />
<div>
<div className="text-sm font-bold text-slate-800">Página de reservas activa</div>
<p className="text-xs text-slate-500">Cuando está activa, cualquier cliente puede agendar desde tu link público.</p>
</div>
</label>
<div>
<label className="label">URL pública de reservas</label>
<div className="flex items-center gap-2">
<div className="flex flex-1 items-center rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
<span className="truncate font-mono text-xs text-slate-600">{bookingUrl}</span>
</div>
<button
onClick={() => {
navigator.clipboard?.writeText(bookingUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="btn btn-secondary"
title="Copiar"
>
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
</button>
<a href={bookingUrl} target="_blank" rel="noreferrer" className="btn btn-secondary" title="Abrir">
<ExternalLink className="h-4 w-4" />
</a>
</div>
<div className="mt-2 flex items-center gap-2">
<label className="label mb-0 shrink-0">/b/</label>
<input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" />
</div>
</div>
</div>
</div>
{/* Política de cancelación */}
<div className="card overflow-hidden">
<SectionTitle icon={ShieldAlert} color="#f17616" title="Política de cancelación" subtitle="Reduce inasistencias: define ventanas y penalizaciones." />
<div className="space-y-4 p-5 pt-0">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label flex items-center gap-1.5"><CalendarClock className="h-3.5 w-3.5 text-slate-400" /> Ventana gratuita (horas)</label>
<input type="number" min={0} step={1} className="input" value={f.cancel_window_hours} onChange={(e) => set("cancel_window_hours", e.target.value)} />
<p className="mt-1 text-[11px] text-slate-500">Cancelación sin costo si se hace con más de estas horas de anticipación.</p>
</div>
<div>
<label className="label flex items-center gap-1.5"><DollarSign className="h-3.5 w-3.5 text-slate-400" /> Penalización (% del servicio)</label>
<input type="number" min={0} max={100} step={5} className="input" value={f.cancel_penalty_pct} onChange={(e) => set("cancel_penalty_pct", e.target.value)} />
<p className="mt-1 text-[11px] text-slate-500">% del precio que se cobra al cancelar dentro de la ventana. 0 = sin penalización.</p>
</div>
</div>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.require_deposit} onChange={(e) => set("require_deposit", e.target.checked)} />
<div className="flex-1">
<div className="text-sm font-bold text-slate-800">Solicitar anticipo al reservar</div>
<p className="text-xs text-slate-500">Compromete al cliente y reduce no-shows.</p>
</div>
</label>
{f.require_deposit && (
<div>
<label className="label">% de anticipo</label>
<input type="number" min={0} max={100} step={5} className="input sm:max-w-xs" value={f.deposit_pct} onChange={(e) => set("deposit_pct", e.target.value)} />
</div>
)}
<div className="rounded-xl bg-amber-50 px-3 py-2 text-xs text-amber-700">
<strong>Ejemplo:</strong> con ventana 24h y 50% de penalización, una cita cancelada con menos de 24h genera un cargo del 50% que verás al cancelarla desde el calendario.
</div>
</div>
</div>
{/* Datos del negocio */}
<div className="card overflow-hidden">
<SectionTitle icon={SettingsIcon} color="#10b981" title="Datos del negocio" />
<div className="grid gap-4 p-5 pt-0 sm:grid-cols-2">
<div><label className="label">Nombre</label><input className="input" value={f.name ?? ""} onChange={(e) => set("name", e.target.value)} /></div>
<div><label className="label">Industria</label><input className="input" value={f.industry ?? ""} onChange={(e) => set("industry", e.target.value)} /></div>
<div><label className="label">Teléfono</label><input className="input" value={f.phone ?? ""} onChange={(e) => set("phone", e.target.value)} /></div>
<div><label className="label">Zona horaria</label>
<select className="input" value={f.timezone ?? "America/Mexico_City"} onChange={(e) => set("timezone", e.target.value)}>
{["America/Mexico_City","America/Monterrey","America/Mazatlan","America/Bogota","America/Lima","America/Santiago","America/Argentina/Buenos_Aires","Europe/Madrid"].map((tz)=><option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
<div className="sm:col-span-2"><label className="label">Dirección</label><input className="input" value={f.address ?? ""} onChange={(e) => set("address", e.target.value)} /></div>
</div>
</div>
<div className="sticky bottom-4 flex justify-end">
<button onClick={() => mut.mutate()} className="btn btn-primary shadow-card" disabled={mut.isPending}>
{mut.isPending ? <Spinner /> : <Save className="h-4 w-4" />} Guardar cambios
</button>
{saved && <span className="ml-3 inline-flex items-center gap-1 self-center text-sm font-semibold text-emerald-600"><Check className="h-4 w-4" /> Guardado</span>}
</div>
</div>
</div>
);
}
function SectionTitle({ icon: Icon, color, title, subtitle }: { icon: any; color: string; title: string; subtitle?: string }) {
return (
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl text-white" style={{ background: color }}>
<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>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Ticket as TicketIcon, Search, CreditCard, Banknote, Smartphone } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { formatCurrency, formatDate, formatTime, paymentLabel } from "../lib/format";
export function TicketsPage() {
const { business } = useAuth();
const symbol = business?.currency_symbol ?? "$";
const [search, setSearch] = useState("");
const [method, setMethod] = useState<string>("all");
const { data, isLoading } = useQuery({
queryKey: ["tickets"],
queryFn: () => api.dashboard.tickets(100),
});
const filtered = useMemo(() => {
let rows = data?.tickets ?? [];
if (method !== "all") rows = rows.filter((t) => t.payment_method === method);
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(
(t) =>
t.client?.name?.toLowerCase().includes(q) ||
t.service?.name?.toLowerCase().includes(q) ||
t.employee?.name?.toLowerCase().includes(q)
);
}
return rows;
}, [data, search, method]);
const total = filtered.reduce((a, t) => a + t.amount + t.tip, 0);
const avg = filtered.length ? total / filtered.length : 0;
const METHODS = [
{ value: "all", label: "Todos", icon: TicketIcon },
{ value: "card", label: "Tarjeta", icon: CreditCard },
{ value: "cash", label: "Efectivo", icon: Banknote },
{ value: "transfer", label: "Transferencia", icon: Smartphone },
];
return (
<div className="flex h-full flex-col">
<PageHeader
title="Tickets"
subtitle="Historial de ventas y transacciones."
actions={
<div className="flex flex-wrap items-center gap-3">
<div className="rounded-xl bg-white px-4 py-2 text-right shadow-soft">
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">Total mostrado</div>
<div className="text-base font-extrabold text-slate-900">{formatCurrency(total, symbol)}</div>
</div>
<div className="rounded-xl bg-white px-4 py-2 text-right shadow-soft">
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">Promedio</div>
<div className="text-base font-extrabold text-slate-900">{formatCurrency(avg, symbol)}</div>
</div>
</div>
}
/>
<div className="flex flex-wrap items-center gap-3 px-5 pt-4 sm:px-7">
<div className="relative min-w-[220px] flex-1 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 cliente, servicio o empleado…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{METHODS.map((m) => (
<button
key={m.value}
onClick={() => setMethod(m.value)}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors ${
method === m.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
<m.icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{m.label}</span>
</button>
))}
</div>
</div>
<div className="flex-1 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !filtered.length ? (
<EmptyState icon={TicketIcon} title="Sin tickets" description="No hay ventas que coincidan con el filtro." />
) : (
<div className="card overflow-hidden">
{/* Mobile card list */}
<div className="divide-y divide-slate-50 sm:hidden">
{filtered.map((t) => (
<div key={t.id} className="px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar name={t.client?.name ?? "?"} size={32} color="#94a3b8" />
<div className="min-w-0">
<div className="truncate font-bold text-slate-900">{t.client?.name}</div>
<div className="font-mono text-[11px] text-slate-400">#{t.id.toString().padStart(5, "0")} · {formatDate(t.created_at)}</div>
</div>
</div>
<div className="text-right">
<div className="font-extrabold text-emerald-600">{formatCurrency(t.amount + t.tip, symbol)}</div>
{t.tip > 0 && <div className="text-[10px] text-slate-400">+{formatCurrency(t.tip, symbol)} propina</div>}
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px]">
<span className="chip" style={{ background: `${t.service?.color}1a`, color: t.service?.color }}>
{t.service?.name}
</span>
<span className="inline-flex items-center gap-1 text-slate-500">
<span className="h-2 w-2 rounded-full" style={{ background: t.employee?.color }} />
{t.employee?.name}
</span>
<span className="ml-auto text-slate-400">{paymentLabel(t.payment_method)}</span>
</div>
</div>
))}
</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">Ticket</th>
<th className="px-4 py-3">Cliente</th>
<th className="px-4 py-3">Servicio</th>
<th className="px-4 py-3">Especialista</th>
<th className="px-4 py-3">Pago</th>
<th className="px-4 py-3 text-right">Monto</th>
<th className="px-4 py-3 text-right">Propina</th>
<th className="px-4 py-3 text-right">Total</th>
</tr>
</thead>
<tbody>
{filtered.map((t) => (
<tr key={t.id} className="border-b border-slate-50 transition-colors hover:bg-slate-50/60">
<td className="px-4 py-3">
<div className="font-mono text-xs font-bold text-slate-700">#{t.id.toString().padStart(5, "0")}</div>
<div className="text-[11px] text-slate-400">{formatDate(t.created_at)} · {formatTime(t.created_at)}</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Avatar name={t.client?.name ?? "?"} size={28} color="#94a3b8" />
<span className="font-semibold text-slate-800">{t.client?.name}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="chip" style={{ background: `${t.service?.color}1a`, color: t.service?.color }}>
{t.service?.name}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ background: t.employee?.color }} />
<span className="text-slate-700">{t.employee?.name}</span>
</div>
</td>
<td className="px-4 py-3 text-xs text-slate-500">{paymentLabel(t.payment_method)}</td>
<td className="px-4 py-3 text-right font-semibold text-slate-700">{formatCurrency(t.amount, symbol)}</td>
<td className="px-4 py-3 text-right text-slate-500">
{t.tip > 0 ? formatCurrency(t.tip, symbol) : "—"}
</td>
<td className="px-4 py-3 text-right font-extrabold text-emerald-600">
{formatCurrency(t.amount + t.tip, symbol)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Building2,
Users,
CalendarCheck,
DollarSign,
Scissors,
Ticket as TicketIcon,
RotateCcw,
Trash2,
Save,
AlertTriangle,
Sparkles,
} from "lucide-react";
import { api } from "../../lib/api";
import { PageHeader, Spinner, EmptyState } from "../../components/ui";
import { Modal } from "../../components/Modal";
import { formatCurrency, formatNumber, formatDate, cn } from "../../lib/format";
export function AdminBusinessDetailPage() {
const { id } = useParams();
const qc = useQueryClient();
const businessId = Number(id);
const [resetOpen, setResetOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["admin", "business", businessId],
queryFn: () => api.admin.business(businessId),
});
const { data: tplData } = useQuery({ queryKey: ["admin", "templates"], queryFn: () => api.admin.templates() });
const b = data?.business;
const stats = data?.stats;
const [name, setName] = useState("");
const [industry, setIndustry] = useState("");
const [plan, setPlan] = useState("trial");
const [status, setStatus] = useState("active");
const [hydrated, setHydrated] = useState(false);
if (b && !hydrated) {
setName(b.name);
setIndustry(b.industry);
setPlan(b.plan ?? "trial");
setStatus(b.status ?? "active");
setHydrated(true);
}
const updateMut = useMutation({
mutationFn: () => api.admin.updateBusiness(businessId, { name, industry, plan, status }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin"] }),
});
const resetMut = useMutation({
mutationFn: (tplKey: string) => api.admin.resetDemo(businessId, { template: tplKey }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin"] });
setResetOpen(false);
},
});
const deleteMut = useMutation({
mutationFn: () => api.admin.deleteBusiness(businessId),
onSuccess: () => (window.location.href = "/admin/businesses"),
});
if (isLoading) {
return <div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>;
}
if (!b) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState title="Negocio no encontrado" action={<Link to="/admin/businesses" className="btn btn-secondary">Volver</Link>} />
</div>
);
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={b.name}
subtitle={`${b.industry} · ${b.template ?? "personalizado"}`}
actions={
<Link to="/admin/businesses" className="btn btn-ghost">
<ArrowLeft className="h-4 w-4" /> Volver
</Link>
}
/>
<div className="grid gap-5 px-5 py-5 sm:px-7 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<div className="card p-5">
<h3 className="mb-3 text-sm font-bold text-slate-900">Estadísticas</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat icon={Users} color="#a855f7" label="Usuarios" value={String(stats?.users ?? 0)} />
<Stat icon={Scissors} color="#3b66ff" label="Servicios" value={String(stats?.services ?? 0)} />
<Stat icon={Users} color="#10b981" label="Clientes" value={String(stats?.clients ?? 0)} />
<Stat icon={CalendarCheck} color="#3b66ff" label="Citas totales" value={formatNumber(stats?.appointments ?? 0)} />
<Stat icon={TicketIcon} color="#f17616" label="Tickets" value={String(stats?.tickets ?? 0)} />
<Stat icon={DollarSign} color="#10b981" label="Ingresos 30d" value={formatCurrency(stats?.revenue_30d ?? 0)} />
</div>
</div>
<div className="card p-5">
<h3 className="mb-3 text-sm font-bold text-slate-900">Datos del negocio</h3>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="label">Industria</label>
<input className="input" value={industry} onChange={(e) => setIndustry(e.target.value)} />
</div>
<div>
<label className="label">Plan</label>
<div className="flex gap-1.5">
{["trial", "pro", "free"].map((p) => (
<button
key={p}
type="button"
onClick={() => setPlan(p)}
className={cn(
"flex-1 rounded-lg border px-2 py-1.5 text-xs font-semibold capitalize transition-colors",
plan === p ? "border-brand-500 bg-brand-50 text-brand-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{p === "trial" ? "Prueba" : p === "pro" ? "Pro" : "Free"}
</button>
))}
</div>
</div>
<div>
<label className="label">Estado</label>
<div className="flex gap-1.5">
{["active", "suspended"].map((s) => (
<button
key={s}
type="button"
onClick={() => setStatus(s)}
className={cn(
"flex-1 rounded-lg border px-2 py-1.5 text-xs font-semibold transition-colors",
status === s ? "border-brand-500 bg-brand-50 text-brand-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{s === "active" ? "Activo" : "Suspendido"}
</button>
))}
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<button onClick={() => updateMut.mutate()} className="btn btn-primary" disabled={updateMut.isPending || !name.trim()}>
{updateMut.isPending ? <Spinner /> : <Save className="h-4 w-4" />} Guardar cambios
</button>
</div>
{updateMut.isSuccess && <div className="mt-2 text-right text-xs font-semibold text-emerald-600">Guardado </div>}
</div>
</div>
<div className="space-y-4">
<div className="card p-5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-brand-500" />
<h3 className="text-sm font-bold text-slate-900">Plantilla demo</h3>
</div>
<p className="mt-1 text-xs text-slate-500">
Recarga este negocio con datos de demostración. Esto <strong>borra</strong> todos sus empleados, servicios, clientes y citas actuales.
</p>
<button onClick={() => setResetOpen(true)} className="btn btn-secondary mt-3 w-full">
<RotateCcw className="h-4 w-4" /> Recargar datos demo
</button>
</div>
<div className="card border-rose-100 p-5">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-rose-500" />
<h3 className="text-sm font-bold text-slate-900">Zona de peligro</h3>
</div>
<p className="mt-1 text-xs text-slate-500">
Elimina permanentemente este negocio y <strong>todos sus datos</strong>, incluidas las cuentas de sus usuarios.
</p>
<button onClick={() => setDeleteOpen(true)} className="btn btn-danger mt-3 w-full">
<Trash2 className="h-4 w-4" /> Eliminar negocio
</button>
</div>
<div className="card p-4 text-[11px] text-slate-400">
<div>ID: <span className="font-mono">#{b.id}</span></div>
<div>Creado: {formatDate(b.created_at)}</div>
<div>Plantilla actual: {b.template ?? "—"}</div>
</div>
</div>
</div>
{/* Reset demo modal */}
<Modal
open={resetOpen}
onClose={() => setResetOpen(false)}
title="Recargar datos de demostración"
subtitle="Elige qué plantilla cargar. Se reemplazarán todos los datos actuales del negocio."
>
<div className="space-y-2">
{(tplData?.templates ?? []).map((t) => (
<button
key={t.key}
onClick={() => resetMut.mutate(t.key)}
disabled={resetMut.isPending}
className="flex w-full items-center gap-3 rounded-xl border border-slate-200 p-3 text-left transition-colors hover:border-brand-300 hover:bg-brand-50 disabled:opacity-50"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white text-brand-600 shadow-soft">
<Building2 className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="font-bold text-slate-900">{t.name}</div>
<div className="text-[11px] text-slate-500">{t.description}</div>
<div className="text-[10px] text-slate-400">{t.employees} esp. · {t.services} serv. · {t.clients} clientes</div>
</div>
<RotateCcw className="h-4 w-4 text-slate-400" />
</button>
))}
{resetMut.isPending && <div className="flex items-center justify-center gap-2 py-3 text-sm text-slate-500"><Spinner /> Recargando</div>}
</div>
</Modal>
{/* Delete confirm modal */}
<Modal
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Eliminar negocio"
size="sm"
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={() => setDeleteOpen(false)} className="btn btn-ghost">Cancelar</button>
<button onClick={() => deleteMut.mutate()} className="btn btn-danger" disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Spinner /> : <Trash2 className="h-4 w-4" />} Eliminar definitivamente
</button>
</div>
}
>
<div className="flex items-start gap-3 rounded-xl bg-rose-50 p-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-rose-500" />
<div className="text-sm text-rose-900">
¿Seguro que deseas eliminar <strong>{b.name}</strong>? Se borrarán para siempre todos sus empleados, servicios, clientes, citas, tickets y cuentas de usuario. Esta acción no se puede deshacer.
</div>
</div>
</Modal>
</div>
);
}
function Stat({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 p-3">
<Icon className="h-4 w-4" style={{ color }} />
<div className="mt-1 text-base font-extrabold text-slate-900">{value}</div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Building2,
Search,
Users,
CalendarCheck,
DollarSign,
ArrowUpRight,
} from "lucide-react";
import { api } from "../../lib/api";
import { PageHeader, Spinner, EmptyState } from "../../components/ui";
import { CreateBusinessModal } from "../../components/admin/CreateBusinessModal";
import { formatCurrency, formatNumber, formatDate } from "../../lib/format";
const PLAN_STYLE: Record<string, { bg: string; fg: string; label: string }> = {
trial: { bg: "#fff7ed", fg: "#b9440b", label: "Prueba" },
pro: { bg: "#eef4ff", fg: "#1c36dc", label: "Pro" },
free: { bg: "#f3f4f6", fg: "#374151", label: "Free" },
};
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
active: { bg: "#ecfdf5", fg: "#047857" },
suspended: { bg: "#fef2f2", fg: "#b91c1c" },
};
export function AdminBusinessesPage() {
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading } = useQuery({ queryKey: ["admin", "businesses"], queryFn: () => api.admin.businesses() });
// open modal when ?new=1
useEffect(() => {
if (params.get("new") === "1") {
setCreateOpen(true);
params.delete("new");
setParams(params, { replace: true });
}
}, [params, setParams]);
const filtered = (data?.businesses ?? []).filter(({ business: b }) =>
!search || b.name.toLowerCase().includes(search.toLowerCase()) || b.industry.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex h-full flex-col">
<PageHeader
title="Negocios"
subtitle={`${data?.businesses.length ?? 0} negocios en la plataforma`}
actions={
<button onClick={() => setCreateOpen(true)} className="btn btn-primary">
<Plus className="h-4 w-4" /> Nuevo negocio
</button>
}
/>
<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 o industria…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !filtered.length ? (
<EmptyState
icon={Building2}
title="Sin negocios"
description="Crea el primer negocio de la plataforma."
action={<button onClick={() => setCreateOpen(true)} className="btn btn-primary"><Plus className="h-4 w-4" /> Crear negocio</button>}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map(({ business: b, stats }) => {
const plan = PLAN_STYLE[b.plan ?? "trial"] ?? PLAN_STYLE.trial;
const status = STATUS_STYLE[b.status ?? "active"] ?? STATUS_STYLE.active;
return (
<Link
key={b.id}
to={`/admin/businesses/${b.id}`}
className="card group p-5 transition-shadow hover:shadow-card"
>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-sm font-bold text-white">
{(b.name ?? "?").slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<h3 className="truncate font-bold text-slate-900">{b.name}</h3>
<span className="chip" style={{ background: plan.bg, color: plan.fg }}>{plan.label}</span>
<span className="chip" style={{ background: status.bg, color: status.fg }}>
{b.status === "active" ? "Activo" : "Suspendido"}
</span>
</div>
<p className="text-xs text-slate-500">{b.industry}</p>
</div>
<ArrowUpRight className="h-4 w-4 text-slate-300 transition-transform group-hover:translate-x-0.5 group-hover:text-brand-500" />
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<Mini icon={Users} color="#a855f7" label="Usuarios" value={String(stats.users)} />
<Mini icon={CalendarCheck} color="#3b66ff" label="Citas" value={formatNumber(stats.appointments)} />
<Mini icon={DollarSign} color="#10b981" label="Ingresos 30d" value={formatCurrency(stats.revenue_30d)} />
</div>
<div className="mt-3 text-[11px] text-slate-400">
Creado {formatDate(b.created_at)} · {b.template ?? "personalizado"}
</div>
</Link>
);
})}
</div>
)}
</div>
<CreateBusinessModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => qc.invalidateQueries({ queryKey: ["admin"] })}
/>
</div>
);
}
function Mini({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 px-2 py-2">
<Icon className="mx-auto h-3.5 w-3.5" style={{ color }} />
<div className="mt-1 truncate text-sm font-bold text-slate-800">{value}</div>
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import {
Building2,
Users,
CalendarCheck,
DollarSign,
ArrowUpRight,
TrendingUp,
Sparkles,
Plus,
} from "lucide-react";
import { api } from "../../lib/api";
import { PageHeader, Spinner } from "../../components/ui";
import { formatCurrency, formatNumber, formatDate, cn } from "../../lib/format";
const PLAN_STYLE: Record<string, { bg: string; fg: string; label: string }> = {
trial: { bg: "#fff7ed", fg: "#b9440b", label: "Prueba" },
pro: { bg: "#eef4ff", fg: "#1c36dc", label: "Pro" },
free: { bg: "#f3f4f6", fg: "#374151", label: "Free" },
};
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
active: { bg: "#ecfdf5", fg: "#047857" },
suspended: { bg: "#fef2f2", fg: "#b91c1c" },
};
export function AdminOverviewPage() {
const { data: ov, isLoading } = useQuery({ queryKey: ["admin", "overview"], queryFn: () => api.admin.overview() });
const { data: biz } = useQuery({ queryKey: ["admin", "businesses"], queryFn: () => api.admin.businesses() });
return (
<div className="flex h-full flex-col">
<PageHeader
title="Resumen de la plataforma"
subtitle="Visión general de todos los negocios en AgendaPro"
actions={
<Link to="/admin/businesses?new=1" className="btn btn-primary">
<Plus className="h-4 w-4" /> Nuevo negocio
</Link>
}
/>
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Kpi icon={Building2} color="#3b66ff" label="Negocios activos" value={isLoading ? "…" : String(ov?.active_businesses ?? 0)} sub={`${ov?.businesses ?? 0} en total`} />
<Kpi icon={Users} color="#a855f7" label="Usuarios" value={isLoading ? "…" : formatNumber(ov?.total_users ?? 0)} sub="owners + empleados" />
<Kpi icon={DollarSign} color="#10b981" label="Ingresos (30d)" value={isLoading ? "…" : formatCurrency(ov?.revenue_30d ?? 0)} sub="suma de todos los negocios" />
<Kpi icon={CalendarCheck} color="#f17616" label="Citas próximas" value={isLoading ? "…" : formatNumber(ov?.upcoming_appointments ?? 0)} sub="programadas" />
</div>
<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">
<Building2 className="h-4 w-4 text-slate-400" />
<h3 className="text-sm font-bold text-slate-900">Negocios recientes</h3>
</div>
<Link to="/admin/businesses" className="text-xs font-semibold text-brand-600 hover:underline">
Ver todos
</Link>
</div>
{!biz ? (
<div className="flex h-32 items-center justify-center"><Spinner /></div>
) : (
<div className="divide-y divide-slate-50">
{biz.businesses.slice(0, 6).map(({ business: b, stats }) => {
const plan = PLAN_STYLE[b.plan ?? "trial"] ?? PLAN_STYLE.trial;
const status = STATUS_STYLE[b.status ?? "active"] ?? STATUS_STYLE.active;
return (
<Link
key={b.id}
to={`/admin/businesses/${b.id}`}
className="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-slate-50/60"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-xs font-bold text-white">
{(b.name ?? "?").slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-slate-900">{b.name}</span>
<span className="chip" style={{ background: plan.bg, color: plan.fg }}>{plan.label}</span>
<span className="chip" style={{ background: status.bg, color: status.fg }}>{b.status === "active" ? "Activo" : "Suspendido"}</span>
</div>
<div className="text-[11px] text-slate-500">
{b.industry} · {formatDate(b.created_at)} · {stats.employees} empleados · {stats.services} servicios
</div>
</div>
<div className="hidden text-right sm:block">
<div className="text-sm font-bold text-slate-900">{formatCurrency(stats.revenue_30d)}</div>
<div className="text-[11px] text-slate-500">{stats.appointments} citas</div>
</div>
<ArrowUpRight className="h-4 w-4 text-slate-300 transition-transform group-hover:translate-x-0.5 group-hover:text-brand-500" />
</Link>
);
})}
{biz.businesses.length === 0 && (
<div className="flex flex-col items-center gap-2 px-5 py-10 text-center">
<Sparkles className="h-6 w-6 text-brand-400" />
<p className="text-sm font-semibold text-slate-700">Aún no hay negocios</p>
<Link to="/admin/businesses?new=1" className="btn btn-primary mt-1">
<Plus className="h-4 w-4" /> Crear el primer negocio
</Link>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
function Kpi({
icon: Icon,
color,
label,
value,
sub,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
sub?: string;
}) {
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">{label}</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>
<div className={cn("mt-3 inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-bold text-emerald-700")}>
<TrendingUp className="h-3 w-3" /> multi-tenant
</div>
</div>
);
}
+862
View File
@@ -0,0 +1,862 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Sparkles,
Clock,
CalendarDays,
User,
Check,
ChevronRight,
ChevronLeft,
Phone,
MapPin,
CalendarCheck,
PartyPopper,
AlertCircle,
Mail,
} from "lucide-react";
import {
getBusiness,
getSlots,
book,
type PublicService,
type PublicSlot,
type BookResponse,
} from "../../lib/publicApi";
import { Spinner } from "../../components/ui";
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
function todayStr() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const STEPS = [
{ n: 1, label: "Servicio" },
{ n: 2, label: "Especialista" },
{ n: 3, label: "Horario" },
{ n: 4, label: "Datos" },
];
export default function BookingPage() {
const { slug } = useParams<{ slug: string }>();
const [step, setStep] = useState(1);
const [serviceId, setServiceId] = useState<number | null>(null);
const [employeeId, setEmployeeId] = useState<number | null>(null);
const [selectedDate, setSelectedDate] = useState<string>(todayStr());
const [selectedSlot, setSelectedSlot] = useState<PublicSlot | null>(null);
const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" });
const [result, setResult] = useState<BookResponse | null>(null);
const businessQuery = useQuery({
queryKey: ["public", "business", slug],
queryFn: () => getBusiness(slug!),
enabled: !!slug,
retry: 1,
});
const data = businessQuery.data;
const business = data?.business;
const services = data?.services ?? [];
const employees = data?.employees ?? [];
const selectedService = useMemo<PublicService | undefined>(
() => services.find((s) => s.id === serviceId),
[services, serviceId]
);
const slotsQuery = useQuery({
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
enabled: step >= 3 && !!serviceId && !!selectedDate && !result,
});
const slots = slotsQuery.data?.slots ?? [];
const bookMut = useMutation({
mutationFn: (payload: Parameters<typeof book>[1]) => book(slug!, payload),
onSuccess: (res) => {
setResult(res);
window.scrollTo({ top: 0, behavior: "smooth" });
},
});
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [step]);
const reset = () => {
setStep(1);
setServiceId(null);
setEmployeeId(null);
setSelectedDate(todayStr());
setSelectedSlot(null);
setClient({ name: "", phone: "", email: "", notes: "" });
setResult(null);
bookMut.reset();
};
if (!slug) {
return <CenteredMessage title="Enlace inválido" description="Falta el identificador del negocio." />;
}
if (businessQuery.isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<Spinner className="h-6 w-6 text-brand-500" />
<span className="text-sm font-medium">Cargando reservas</span>
</div>
</div>
);
}
if (businessQuery.isError || !business || !data) {
return (
<CenteredMessage
title="No encontramos este negocio"
description={businessQuery.error instanceof Error ? businessQuery.error.message : "El enlace puede ser incorrecto o el negocio no está disponible."}
/>
);
}
if (result) {
return <Confirmation result={result} businessName={business.name} onReset={reset} />;
}
if (!business.booking_enabled) {
return (
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={0} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="card w-full max-w-md p-8 text-center shadow-card animate-slide-up">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600">
<AlertCircle className="h-7 w-7" />
</div>
<h1 className="mt-4 text-xl font-extrabold text-slate-900">Reservas desactivadas</h1>
<p className="mt-2 text-sm text-slate-500">
{business.name} no está recibiendo reservas en línea en este momento.
</p>
{business.phone && (
<a href={`tel:${business.phone}`} className="btn btn-primary mx-auto mt-5">
<Phone className="h-4 w-4" /> Llamar al {business.phone}
</a>
)}
</div>
</main>
</div>
);
}
const goNext = () => setStep((s) => Math.min(4, s + 1));
const goBack = () => setStep((s) => Math.max(1, s - 1));
const pickService = (id: number) => {
if (serviceId !== id) {
setServiceId(id);
setSelectedSlot(null);
}
};
const pickEmployee = (id: number | null) => {
setEmployeeId(id);
setSelectedSlot(null);
};
const pickSlot = (slot: PublicSlot) => {
setSelectedSlot(slot);
setStep(4);
};
const handleBook = () => {
if (!selectedService || !selectedSlot) return;
if (!client.name.trim()) return;
bookMut.mutate({
service_id: selectedService.id,
employee_id: selectedSlot.employee_id,
start_at: selectedSlot.iso,
client: {
name: client.name.trim(),
email: client.email.trim() || undefined,
phone: client.phone.trim() || undefined,
notes: client.notes.trim() || undefined,
},
});
};
const canContinue =
(step === 1 && !!serviceId) ||
step === 2 ||
(step === 3 && !!selectedSlot) ||
(step === 4 && client.name.trim().length > 0);
const currency = business.currency_symbol || "$";
const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar";
const onPrimary = step === 4 ? handleBook : goNext;
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={step} />
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28">
{step === 1 && (
<Hero business={business} />
)}
<div className="mt-4">
{step === 1 && (
<StepShell
icon={<Sparkles className="h-4 w-4" />}
title="Elige un servicio"
subtitle="Selecciona el servicio que deseas reservar."
>
<ServiceGrid
services={services}
currency={currency}
selectedId={serviceId}
onPick={pickService}
/>
</StepShell>
)}
{step === 2 && (
<StepShell
icon={<User className="h-4 w-4" />}
title="Elige un especialista"
subtitle="¿Tienes una preferencia? O déjalo en cualquiera."
>
<EmployeeList
employees={employees}
selectedId={employeeId}
onPick={pickEmployee}
/>
</StepShell>
)}
{step === 3 && selectedService && (
<StepShell
icon={<CalendarDays className="h-4 w-4" />}
title="Elige fecha y hora"
subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`}
>
<DateTimePicker
date={selectedDate}
onDate={(d) => {
setSelectedDate(d);
setSelectedSlot(null);
}}
min={todayStr()}
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
selectedSlot={selectedSlot}
onPick={pickSlot}
/>
</StepShell>
)}
{step === 4 && selectedService && selectedSlot && (
<StepShell
icon={<CalendarCheck className="h-4 w-4" />}
title="Tus datos"
subtitle="Necesitamos algunos datos para confirmar tu cita."
>
<DetailsForm
client={client}
setClient={setClient}
summary={
<>
<SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
<SummaryRow label="Especialista" value={selectedSlot.employee_name} />
</>
}
/>
</StepShell>
)}
</div>
</main>
<ActionBar
hidden={!!result}
step={step}
goBack={goBack}
onPrimary={onPrimary}
primaryLabel={primaryLabel}
canContinue={canContinue}
busy={bookMut.isPending}
priceLabel={
selectedService ? formatCurrency(selectedService.price, currency) : undefined
}
serviceName={selectedService?.name}
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
/>
<FooterNote name={business.name} />
</div>
);
}
function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) {
const current = STEPS.find((s) => s.n === step);
return (
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-extrabold text-slate-900">{name}</div>
<div className="truncate text-[11px] text-slate-500">{industry}</div>
</div>
</div>
{step > 0 && (
<div className="flex items-center gap-1.5">
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">
Paso {step} de 4
</span>
<div className="hidden items-center gap-1 md:flex">
{STEPS.map((s) => (
<div
key={s.n}
className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors",
s.n === step
? "bg-brand-500 text-white"
: s.n < step
? "bg-brand-50 text-brand-700"
: "bg-slate-100 text-slate-400"
)}
>
{s.n < step ? <Check className="h-3 w-3" /> : <span>{s.n}</span>}
<span>{s.label}</span>
</div>
))}
</div>
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
<span>{step}</span>
<span className="font-semibold text-brand-400">/</span>
<span className="text-brand-400">4</span>
<span className="text-brand-300">·</span>
<span>{current?.label}</span>
</div>
</div>
)}
</div>
</header>
);
}
function Hero({ business }: { business: { name: string; industry: string; phone: string | null; address: string | null } }) {
return (
<section className="overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 via-brand-600 to-brand-800 p-6 text-white shadow-card animate-fade-in sm:p-8">
<div className="relative z-10">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-semibold backdrop-blur">
<Sparkles className="h-3 w-3" /> Reservas en línea
</span>
<h1 className="mt-3 text-2xl font-extrabold leading-tight tracking-tight sm:text-3xl">
{business.name}
</h1>
<p className="mt-1.5 max-w-md text-sm text-brand-100">
Reserva tu cita en {business.industry.toLowerCase()} en pocos segundos. Sin llamadas, sin esperas.
</p>
{(business.address || business.phone) && (
<div className="mt-4 flex flex-wrap gap-2 text-xs">
{business.address && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-white/10 px-2.5 py-1.5 backdrop-blur">
<MapPin className="h-3.5 w-3.5" /> {business.address}
</span>
)}
{business.phone && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-white/10 px-2.5 py-1.5 backdrop-blur">
<Phone className="h-3.5 w-3.5" /> {business.phone}
</span>
)}
</div>
)}
</div>
</section>
);
}
function StepShell({
icon,
title,
subtitle,
children,
}: {
icon: React.ReactNode;
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<div className="animate-slide-up">
<div className="mb-4 flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
{icon}
</div>
<div>
<h2 className="text-base font-extrabold text-slate-900 sm:text-lg">{title}</h2>
{subtitle && <p className="text-xs text-slate-500 sm:text-sm">{subtitle}</p>}
</div>
</div>
{children}
</div>
);
}
function ServiceGrid({
services,
currency,
selectedId,
onPick,
}: {
services: PublicService[];
currency: string;
selectedId: number | null;
onPick: (id: number) => void;
}) {
if (services.length === 0) {
return (
<div className="card p-8 text-center text-sm text-slate-500">
Este negocio aún no tiene servicios disponibles para reservar.
</div>
);
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{services.map((s) => {
const active = s.id === selectedId;
return (
<button
key={s.id}
type="button"
onClick={() => onPick(s.id)}
className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-4 text-left transition-all",
active
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300 hover:shadow-soft"
)}
>
<div className="flex items-start justify-between gap-2">
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-bold text-white"
style={{ background: s.color || "#3b66ff" }}
>
{s.category}
</span>
{active && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</div>
<div>
<div className="font-bold text-slate-900">{s.name}</div>
{s.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-slate-500">{s.description}</p>
)}
</div>
<div className="mt-1 flex items-center justify-between">
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<Clock className="h-3.5 w-3.5" /> {s.duration_min} min
</span>
<span className="text-base font-extrabold text-brand-700">
{formatCurrency(s.price, currency)}
</span>
</div>
</button>
);
})}
</div>
);
}
function EmployeeList({
employees,
selectedId,
onPick,
}: {
employees: { id: number; name: string; role: string; color: string }[];
selectedId: number | null;
onPick: (id: number | null) => void;
}) {
const anyActive = selectedId === null;
return (
<div className="space-y-2.5">
<button
type="button"
onClick={() => onPick(null)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border p-3.5 text-left transition-all",
anyActive
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300"
)}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-bold text-slate-900">Cualquiera</div>
<div className="text-xs text-slate-500">Asigna al especialista disponible</div>
</div>
{anyActive && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
{employees.map((e) => {
const active = e.id === selectedId;
return (
<button
key={e.id}
type="button"
onClick={() => onPick(e.id)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border p-3.5 text-left transition-all",
active
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300"
)}
>
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ background: e.color || "#3b66ff" }}
>
{e.name.split(" ").filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase()).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-bold text-slate-900">{e.name}</div>
<div className="truncate text-xs text-slate-500">{e.role}</div>
</div>
{active && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
);
})}
</div>
);
}
function DateTimePicker({
date,
onDate,
min,
slots,
loading,
isError,
selectedSlot,
onPick,
}: {
date: string;
onDate: (d: string) => void;
min: string;
slots: PublicSlot[];
loading: boolean;
isError: boolean;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
return (
<div className="space-y-4">
<div>
<label className="label flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
</label>
<input
type="date"
className="input"
value={date}
min={min}
onChange={(e) => e.target.value && onDate(e.target.value)}
/>
</div>
<div>
<label className="label flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
</label>
<div className="card p-4">
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-slate-400">
<Spinner className="text-brand-500" />
<span className="text-sm font-medium">Buscando horarios</span>
</div>
)}
{!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
</div>
)}
{!loading && !isError && slots.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarDays className="h-6 w-6 text-slate-300" />
<p className="text-sm font-medium text-slate-600">Sin horarios disponibles</p>
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
</div>
)}
{!loading && !isError && slots.length > 0 && (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((slot) => {
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
return (
<button
key={`${slot.iso}-${slot.employee_id}`}
type="button"
onClick={() => onPick(slot)}
className={cn(
"rounded-xl border px-2 py-2.5 text-sm font-bold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-soft"
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
)}
>
{slot.time}
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function DetailsForm({
client,
setClient,
summary,
}: {
client: { name: string; phone: string; email: string; notes: string };
setClient: (c: { name: string; phone: string; email: string; notes: string }) => void;
summary: React.ReactNode;
}) {
return (
<div className="space-y-4">
<div className="card divide-y divide-slate-100 p-2">{summary}</div>
<div className="card p-4">
<div className="grid gap-3">
<div>
<label className="label">Nombre completo *</label>
<div className="relative">
<User 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"
value={client.name}
onChange={(e) => setClient({ ...client, name: e.target.value })}
placeholder="Tu nombre"
autoFocus
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Teléfono</label>
<div className="relative">
<Phone 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"
inputMode="tel"
value={client.phone}
onChange={(e) => setClient({ ...client, phone: e.target.value })}
placeholder="+52 …"
/>
</div>
</div>
<div>
<label className="label">Correo</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
className="input pl-9"
value={client.email}
onChange={(e) => setClient({ ...client, email: e.target.value })}
placeholder="[email protected]"
/>
</div>
</div>
</div>
<div>
<label className="label">Notas (opcional)</label>
<textarea
className="textarea"
rows={2}
value={client.notes}
onChange={(e) => setClient({ ...client, notes: e.target.value })}
placeholder="Preferencias, alergias, indicaciones…"
/>
</div>
</div>
</div>
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{label}</span>
<span className="text-right text-sm font-bold text-slate-800">{value}</span>
</div>
);
}
function ActionBar({
hidden,
step,
goBack,
onPrimary,
primaryLabel,
canContinue,
busy,
priceLabel,
serviceName,
slotLabel,
}: {
hidden: boolean;
step: number;
goBack: () => void;
onPrimary: () => void;
primaryLabel: string;
canContinue: boolean;
busy: boolean;
priceLabel?: string;
serviceName?: string;
slotLabel?: string;
}) {
if (hidden) return null;
return (
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-4 py-3 sm:px-6">
{step > 1 && (
<button onClick={goBack} className="btn btn-ghost shrink-0" type="button">
<ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span>
</button>
)}
<div className="min-w-0 flex-1">
{serviceName ? (
<>
<div className="truncate text-sm font-bold text-slate-900">{serviceName}</div>
<div className="truncate text-xs text-slate-500">
{slotLabel ? slotLabel : priceLabel}
</div>
</>
) : (
<div className="text-sm font-medium text-slate-400">
{step === 1 ? "Selecciona un servicio" : "Continúa para reservar"}
</div>
)}
</div>
<button
onClick={onPrimary}
disabled={!canContinue || busy}
className="btn btn-primary shrink-0"
type="button"
>
{busy ? <Spinner /> : <>{primaryLabel} <ChevronRight className="h-4 w-4" /></>}
</button>
</div>
</div>
);
}
function FooterNote({ name }: { name: string }) {
return (
<div className="border-t border-slate-200/60 bg-white/50 px-4 py-3 text-center text-[11px] text-slate-400">
Reserva en línea · {name} · powered by AgendaPro
</div>
);
}
function Confirmation({
result,
businessName,
onReset,
}: {
result: BookResponse;
businessName: string;
onReset: () => void;
}) {
const { appointment } = result;
const currency = result.business.currency_symbol || "$";
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]">
<TopBar name={businessName} industry="Reserva confirmada" step={0} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="w-full max-w-md animate-slide-up">
<div className="card overflow-hidden p-0 text-center shadow-card">
<div className="relative overflow-hidden bg-gradient-to-br from-emerald-500 to-emerald-600 px-6 py-8 text-white">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/15 blur-2xl" />
<div className="relative z-10 mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/20 backdrop-blur">
<PartyPopper className="h-8 w-8" />
</div>
<h1 className="relative z-10 mt-3 text-xl font-extrabold">¡Reserva confirmada!</h1>
<p className="relative z-10 mt-1 text-sm text-emerald-50">
Te esperamos en {businessName}.
</p>
</div>
<div className="space-y-1 p-5 text-left">
<SummaryRow label="Servicio" value={appointment.service_name} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Fecha" value={formatDate(appointment.start_at, { weekday: "long" })} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Especialista" value={appointment.employee_name} />
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between gap-3 px-3 py-3">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>
<span className="text-lg font-extrabold text-brand-700">
{formatCurrency(appointment.price, currency)}
</span>
</div>
</div>
</div>
<button onClick={onReset} className="btn btn-secondary mt-4 w-full" type="button">
<ChevronLeft className="h-4 w-4" /> Reservar otra cita
</button>
<p className="mt-3 text-center text-xs text-slate-400">
Guarda esta página o toma una captura con los datos de tu cita.
</p>
</div>
</main>
</div>
);
}
function CenteredMessage({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f6f7fb] px-5">
<div className="card w-full max-w-md p-8 text-center shadow-card">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-100 text-rose-500">
<AlertCircle className="h-6 w-6" />
</div>
<h1 className="mt-3 text-lg font-extrabold text-slate-900">{title}</h1>
<p className="mt-1.5 text-sm text-slate-500">{description}</p>
</div>
</div>
);
}