feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed
- Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
This commit is contained in:
+3
-1
@@ -13,6 +13,7 @@ import { ClientDetailPage } from "./pages/ClientDetailPage";
|
||||
import { CashPage } from "./pages/CashPage";
|
||||
import { NotificationsPage } from "./pages/NotificationsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { MyPerformancePage } from "./pages/MyPerformancePage";
|
||||
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
|
||||
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
|
||||
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
|
||||
@@ -25,7 +26,7 @@ function Protected() {
|
||||
<div className="flex h-screen items-center justify-center bg-[#f6f7fb]">
|
||||
<div className="flex flex-col items-center gap-3 text-slate-500">
|
||||
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
<span className="text-sm font-medium">Cargando AgendaPro…</span>
|
||||
<span className="text-sm font-medium">Cargando AgendaMax…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -59,6 +60,7 @@ function Protected() {
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/clients/:id" element={<ClientDetailPage />} />
|
||||
<Route path="/me" element={<MyPerformancePage />} />
|
||||
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
|
||||
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
|
||||
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function AdminShell() {
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
|
||||
<div className="text-[11px] font-medium text-slate-500">Consola Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Wallet,
|
||||
Bell,
|
||||
Settings,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { initials, cn } from "../lib/format";
|
||||
@@ -24,12 +25,16 @@ interface NavItem {
|
||||
label: string;
|
||||
icon: typeof CalendarDays;
|
||||
ownerOnly?: boolean;
|
||||
employeeOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: "/dashboard", label: "Tablero", icon: LayoutDashboard, ownerOnly: true },
|
||||
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
|
||||
{ to: "/clients", label: "Clientes", icon: Users },
|
||||
{ to: "/me", label: "Mi desempeño", icon: BarChart3, employeeOnly: true },
|
||||
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
|
||||
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
|
||||
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
|
||||
@@ -45,7 +50,11 @@ export function AppShell() {
|
||||
|
||||
if (!user) return null;
|
||||
const isOwner = user.role === "owner";
|
||||
const items = NAV.filter((n) => !n.ownerOnly || isOwner);
|
||||
const items = NAV.filter((n) => {
|
||||
if (n.ownerOnly) return isOwner;
|
||||
if (n.employeeOnly) return user.role === "employee" || (isOwner && !!user.employee_id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const SidebarContent = (
|
||||
<>
|
||||
@@ -54,7 +63,7 @@ export function AppShell() {
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
|
||||
<div className="text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,9 +96,11 @@ export function AppShell() {
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto space-y-2 px-2 pb-3">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-2.5">
|
||||
<DemoSwitcher />
|
||||
</div>
|
||||
{DEMO && (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-2.5">
|
||||
<DemoSwitcher />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 rounded-xl p-2">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
@@ -151,7 +162,7 @@ export function AppShell() {
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 text-white">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-extrabold">AgendaPro</span>
|
||||
<span className="text-sm font-extrabold">AgendaMax</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
|
||||
+251
-101
@@ -11,6 +11,10 @@ import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Stethoscope,
|
||||
Phone,
|
||||
Mail,
|
||||
Tag,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
@@ -32,6 +36,13 @@ const TIME_SUGGESTIONS = [
|
||||
"16:00","16:30","17:00","17:30","18:00","18:30","19:00","19:30","20:00",
|
||||
];
|
||||
|
||||
// joinAppointment attaches notes + no_show_count directly on the client object,
|
||||
// but shared/types Appointment.client Pick omits them — augment locally.
|
||||
type ApptClientMeta = NonNullable<Appointment["client"]> & {
|
||||
notes?: string | null;
|
||||
no_show_count?: number;
|
||||
};
|
||||
|
||||
function toLocalInput(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return { date: "", time: "10:00" };
|
||||
@@ -51,9 +62,18 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
const [serviceId, setServiceId] = useState<number | null>(null);
|
||||
const [employeeId, setEmployeeId] = useState<number | null>(user?.employee_id ?? null);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [pickedClientName, setPickedClientName] = useState<string>("");
|
||||
const [pickedClientPhone, setPickedClientPhone] = useState<string>("");
|
||||
const [pickedClientEmail, setPickedClientEmail] = useState<string>("");
|
||||
const [pickedClientTags, setPickedClientTags] = useState<string>("");
|
||||
const [pickedClientNotes, setPickedClientNotes] = useState<string>("");
|
||||
const [pickedClientNoShows, setPickedClientNoShows] = useState<number>(0);
|
||||
const [clientMode, setClientMode] = useState<"existing" | "new">("existing");
|
||||
const [newClient, setNewClient] = useState({ name: "", phone: "", email: "", notes: "" });
|
||||
const [search, setSearch] = useState("");
|
||||
// When editing, hide the picker behind an explicit "Cambiar cliente" action so the
|
||||
// current client is always visible — never a stray "?" or empty search field.
|
||||
const [changingClient, setChangingClient] = useState(false);
|
||||
const [dateInput, setDateInput] = useState("");
|
||||
const [timeInput, setTimeInput] = useState("10:00");
|
||||
const [duration, setDuration] = useState(60);
|
||||
@@ -88,7 +108,15 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
setServiceId(appointment.service_id);
|
||||
setEmployeeId(appointment.employee_id);
|
||||
setClientId(appointment.client_id);
|
||||
setPickedClientName(appointment.client?.name ?? "");
|
||||
setPickedClientPhone(appointment.client?.phone ?? "");
|
||||
setPickedClientEmail(appointment.client?.email ?? "");
|
||||
setPickedClientTags(appointment.client?.tags ?? "");
|
||||
const ac = appointment.client as ApptClientMeta | undefined;
|
||||
setPickedClientNotes(ac?.notes ?? "");
|
||||
setPickedClientNoShows(Number(ac?.no_show_count ?? 0));
|
||||
setClientMode("existing");
|
||||
setChangingClient(false);
|
||||
const d = toLocalInput(appointment.start_at);
|
||||
setDateInput(d.date);
|
||||
setTimeInput(d.time);
|
||||
@@ -107,7 +135,14 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
setServiceId(null);
|
||||
setEmployeeId(user?.employee_id ?? null);
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
setClientMode("existing");
|
||||
setChangingClient(true);
|
||||
setNewClient({ name: "", phone: "", email: "", notes: "" });
|
||||
setSearch("");
|
||||
const d = toLocalInput(draftSlot.start);
|
||||
@@ -297,122 +332,237 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="label mb-0">Cliente</label>
|
||||
<div className="flex rounded-lg border border-slate-200 p-0.5">
|
||||
{!isEditing && clientMode === "existing" && !clientId && (
|
||||
<div className="flex rounded-lg border border-slate-200 p-0.5">
|
||||
<button
|
||||
onClick={() => setClientMode("existing")}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
|
||||
clientMode === "existing" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
|
||||
)}
|
||||
>
|
||||
Existente
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClientMode("new")}
|
||||
className="rounded-md px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<UserPlus className="mr-1 inline h-3 w-3" /> Nuevo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && clientId && (
|
||||
<button
|
||||
onClick={() => setClientMode("existing")}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
|
||||
clientMode === "existing" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Existente
|
||||
Cambiar cliente
|
||||
</button>
|
||||
)}
|
||||
{isEditing && !changingClient && (
|
||||
<button
|
||||
onClick={() => setClientMode("new")}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
|
||||
clientMode === "new" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => setChangingClient(true)}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<UserPlus className="mr-1 inline h-3 w-3" /> Nuevo
|
||||
Cambiar cliente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isEditing && changingClient && clientId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChangingClient(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Mantener actual
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clientMode === "existing" ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<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={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setClientId(null);
|
||||
}}
|
||||
/>
|
||||
{searching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
|
||||
</div>
|
||||
{clientId && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2">
|
||||
<Avatar name={searchResults?.clients.find((c) => c.id === clientId)?.name ?? "?"} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-slate-800">
|
||||
{searchResults?.clients.find((c) => c.id === clientId)?.name}
|
||||
</div>
|
||||
{/* Current client card (edit mode, or after picking existing client) */}
|
||||
{!changingClient && clientId && pickedClientName && (
|
||||
<div className="rounded-xl border border-brand-200 bg-brand-50/70 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={pickedClientName} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{pickedClientName}</div>
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-brand-600" />
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-brand-600" />
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-slate-600">
|
||||
{pickedClientPhone && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Phone className="h-3 w-3 text-slate-400" />
|
||||
{pickedClientPhone}
|
||||
</span>
|
||||
)}
|
||||
{pickedClientEmail && (
|
||||
<span className="inline-flex items-center gap-1 truncate">
|
||||
<Mail className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<span className="truncate">{pickedClientEmail}</span>
|
||||
</span>
|
||||
)}
|
||||
{!pickedClientPhone && !pickedClientEmail && (
|
||||
<span className="text-slate-400">Sin datos de contacto</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pickedClientTags && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
|
||||
<Tag className="h-3 w-3 text-slate-400" />
|
||||
{pickedClientTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => (
|
||||
<span key={t} className="chip bg-white text-slate-600">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!clientId && search && searchResults && (
|
||||
<div className="mt-2 max-h-48 overflow-y-auto rounded-xl border border-slate-100">
|
||||
{searchResults.clients.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-sm text-slate-500">
|
||||
Sin resultados.{" "}
|
||||
<button onClick={() => setClientMode("new")} className="font-semibold text-brand-600">
|
||||
Crear nuevo cliente
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
searchResults.clients.slice(0, 12).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setClientId(c.id);
|
||||
setSearch(c.name);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 border-b border-slate-50 px-3 py-2 text-left transition-colors last:border-0 hover:bg-slate-50"
|
||||
>
|
||||
<Avatar name={c.name} size={28} color="#94a3b8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{c.name}</div>
|
||||
<div className="truncate text-[11px] text-slate-500">
|
||||
{c.phone ?? c.email ?? "Sin contacto"}
|
||||
</div>
|
||||
{pickedClientNoShows >= 1 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
|
||||
<span
|
||||
title="Ausencias previas sin avisar"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||
pickedClientNoShows >= 2
|
||||
? "bg-amber-100 text-amber-800 ring-1 ring-amber-300"
|
||||
: "bg-amber-50 text-amber-700"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Ausencias previas: {pickedClientNoShows}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pickedClientNotes && (
|
||||
<div className="mt-2 rounded-lg border border-brand-200/60 bg-white/60 px-2.5 py-1.5 text-[11px] text-slate-700">
|
||||
<div className="font-semibold text-slate-600">Notas del cliente</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap break-words">{pickedClientNotes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Picker (existing/new): visible when explicitly changing (edit) OR
|
||||
when no existing client has been picked yet (create). Once an existing
|
||||
client is picked in create mode, the card above takes over. */}
|
||||
{(changingClient || (!isEditing && !clientId)) && (
|
||||
<>
|
||||
{clientMode === "existing" ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<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={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
}}
|
||||
/>
|
||||
{searching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
|
||||
</div>
|
||||
{!clientId && search && searchResults && (
|
||||
<div className="mt-2 max-h-48 overflow-y-auto rounded-xl border border-slate-100">
|
||||
{searchResults.clients.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-sm text-slate-500">
|
||||
Sin resultados.{" "}
|
||||
<button onClick={() => setClientMode("new")} className="font-semibold text-brand-600">
|
||||
Crear nuevo cliente
|
||||
</button>
|
||||
</div>
|
||||
{c.tags && (
|
||||
<span className="chip bg-slate-100 text-slate-600">{c.tags.split(",")[0]}</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
searchResults.clients.slice(0, 12).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setClientId(c.id);
|
||||
setPickedClientName(c.name);
|
||||
setPickedClientPhone(c.phone ?? "");
|
||||
setPickedClientEmail(c.email ?? "");
|
||||
setPickedClientTags(c.tags ?? "");
|
||||
setPickedClientNotes(c.notes ?? "");
|
||||
setPickedClientNoShows(Number(c.stats?.no_show_count ?? 0));
|
||||
setSearch(c.name);
|
||||
if (isEditing) setChangingClient(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 border-b border-slate-50 px-3 py-2 text-left transition-colors last:border-0 hover:bg-slate-50"
|
||||
>
|
||||
<Avatar name={c.name} size={28} color="#94a3b8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{c.name}</div>
|
||||
<div className="truncate text-[11px] text-slate-500">
|
||||
{c.phone ?? c.email ?? "Sin contacto"}
|
||||
</div>
|
||||
</div>
|
||||
{c.tags && (
|
||||
<span className="chip bg-slate-100 text-slate-600">{c.tags.split(",")[0]}</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!clientId && !search && (
|
||||
<p className="mt-1 text-xs text-slate-400">Escribe para buscar un cliente existente.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="label">Nombre completo *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newClient.name}
|
||||
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
|
||||
placeholder="Nombre del cliente"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Teléfono</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newClient.phone}
|
||||
onChange={(e) => setNewClient({ ...newClient, phone: e.target.value })}
|
||||
placeholder="+52 …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Correo</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newClient.email}
|
||||
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!clientId && !search && (
|
||||
<p className="mt-1 text-xs text-slate-400">Escribe para buscar un cliente existente.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="label">Nombre completo *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newClient.name}
|
||||
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
|
||||
placeholder="Nombre del cliente"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Teléfono</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newClient.phone}
|
||||
onChange={(e) => setNewClient({ ...newClient, phone: e.target.value })}
|
||||
placeholder="+52 …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Correo</label>
|
||||
<input
|
||||
className="input"
|
||||
value={newClient.email}
|
||||
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ChevronDown, UserCog, Check } from "lucide-react";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
||||
|
||||
export function DemoSwitcher() {
|
||||
const { user, switchUser } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -13,6 +15,7 @@ export function DemoSwitcher() {
|
||||
api.auth.demoUsers().then((r) => setUsers(r.users)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!DEMO) return null;
|
||||
if (!user) return null;
|
||||
|
||||
const pick = async (email: string) => {
|
||||
|
||||
+25
-1
@@ -66,6 +66,12 @@ body {
|
||||
color: #0f172a;
|
||||
}
|
||||
.fc .fc-toolbar.fc-header-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #ffffff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin: -0.4rem -0.6rem 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
@@ -101,11 +107,18 @@ body {
|
||||
text-transform: capitalize;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.fc .fc-button:not(:disabled):hover {
|
||||
.fc .fc-button:not(:disabled):not(.fc-button-active):hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active {
|
||||
color: #ffffff;
|
||||
background: var(--fc-button-active-bg-color, #3b66ff);
|
||||
border-color: var(--fc-button-active-border-color, #3b66ff);
|
||||
}
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active:hover {
|
||||
background: #2447f5;
|
||||
border-color: #2447f5;
|
||||
color: #ffffff;
|
||||
}
|
||||
.fc .fc-button:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -136,6 +149,7 @@ body {
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-event:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -149,6 +163,16 @@ body {
|
||||
}
|
||||
.fc-timegrid-event {
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.fc-timegrid-event-harness {
|
||||
margin-right: 2px !important;
|
||||
}
|
||||
/* When slotEventOverlap is false, FullCalendar puts a small gap between
|
||||
side-by-side events; tighten it a touch so each event gets more readable
|
||||
width without bleeding into its neighbour. */
|
||||
.fc .fc-timegrid-col-events {
|
||||
column-gap: 2px;
|
||||
}
|
||||
|
||||
/* List view (great on mobile) */
|
||||
|
||||
+25
-1
@@ -120,7 +120,8 @@ export const api = {
|
||||
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
dashboard: {
|
||||
overview: () => request<DashboardOverview>("/dashboard/overview"),
|
||||
overview: (range?: number) =>
|
||||
request<DashboardOverview>(`/dashboard/overview${range ? `?range=${range}` : ""}`),
|
||||
bestEmployees: (range = "30") => request<{ employees: RankedEmployee[] }>(`/dashboard/best-employees?range=${range}`),
|
||||
bestServices: (range = "30") => request<{ services: RankedService[] }>(`/dashboard/best-services?range=${range}`),
|
||||
topTickets: (range = "30", limit = 8) =>
|
||||
@@ -135,6 +136,29 @@ export const api = {
|
||||
tickets: (limit = 50) => request<{ tickets: Ticket[] }>(`/dashboard/tickets?limit=${limit}`),
|
||||
commissions: (range = "30") => request<{ rows: any[]; total: number }>(`/dashboard/commissions?range=${range}`),
|
||||
},
|
||||
me: {
|
||||
performance: () =>
|
||||
request<{
|
||||
appointments_today: number;
|
||||
upcoming: number;
|
||||
revenue_30d: number;
|
||||
avg_rating: number;
|
||||
share_pct: number;
|
||||
next_appointment: { start_at: string; service_name: string; client_name: string } | null;
|
||||
}>("/me/performance"),
|
||||
commissions: () =>
|
||||
request<{
|
||||
rows: {
|
||||
appointment_id: number | null;
|
||||
service_name: string;
|
||||
client_name: string;
|
||||
amount: number;
|
||||
commission: number;
|
||||
created_at: string;
|
||||
}[];
|
||||
total: number;
|
||||
}>("/me/commissions"),
|
||||
},
|
||||
settings: {
|
||||
get: () => request<{ settings: any }>("/settings"),
|
||||
update: (data: any) => request<{ settings: any }>("/settings", { method: "PATCH", body: JSON.stringify(data) }),
|
||||
|
||||
+3
-14
@@ -1,3 +1,6 @@
|
||||
import type { BookResponse } from "../../shared/types";
|
||||
export type { BookResponse };
|
||||
|
||||
const BASE = "/api/public";
|
||||
|
||||
export interface PublicBusiness {
|
||||
@@ -64,20 +67,6 @@ export interface BookPayload {
|
||||
client: BookClient;
|
||||
}
|
||||
|
||||
export interface BookResponse {
|
||||
appointment: {
|
||||
id: number;
|
||||
start_at: string;
|
||||
price: number;
|
||||
service_name: string;
|
||||
employee_id: number;
|
||||
employee_name: string;
|
||||
reasons: string[];
|
||||
};
|
||||
business: { name: string; currency_symbol: string };
|
||||
client_created: boolean;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
|
||||
+62
-11
@@ -9,9 +9,10 @@ import type {
|
||||
EventClickArg,
|
||||
EventDropArg,
|
||||
} from "@fullcalendar/core";
|
||||
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||
import esLocale from "@fullcalendar/core/locales/es";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarDays, Plus, Filter, RotateCcw } from "lucide-react";
|
||||
import { CalendarDays, Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import type { Appointment } from "../../shared/types";
|
||||
@@ -30,6 +31,8 @@ export function CalendarPage() {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const calRef = useRef<FullCalendar>(null);
|
||||
const pendingRevert = useRef<(() => void) | null>(null);
|
||||
const [moveError, setMoveError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Appointment | null>(null);
|
||||
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
|
||||
@@ -100,16 +103,27 @@ export function CalendarPage() {
|
||||
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"] }),
|
||||
onSuccess: () => {
|
||||
pendingRevert.current = null;
|
||||
setMoveError(null);
|
||||
qc.invalidateQueries({ queryKey: ["appointments"] });
|
||||
},
|
||||
onError: () => {
|
||||
pendingRevert.current?.();
|
||||
pendingRevert.current = null;
|
||||
setMoveError("No se pudo reagendar la cita (quizá se ocupó el horario). Se regresó a su lugar.");
|
||||
},
|
||||
});
|
||||
|
||||
const onDrop = (arg: EventDropArg) => {
|
||||
pendingRevert.current = arg.revert;
|
||||
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 onResize = (arg: EventResizeDoneArg) => {
|
||||
pendingRevert.current = arg.revert;
|
||||
const id = Number(arg.event.id);
|
||||
moveMutation.mutate({
|
||||
id,
|
||||
@@ -164,6 +178,21 @@ export function CalendarPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{moveError && (
|
||||
<div className="mx-5 mt-2 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 sm:mx-7">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="flex-1">{moveError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoveError(null)}
|
||||
className="shrink-0 text-amber-500 hover:text-amber-700"
|
||||
aria-label="Cerrar aviso"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
@@ -198,7 +227,7 @@ export function CalendarPage() {
|
||||
</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">
|
||||
<div className="card flex h-full min-h-0 flex-col overflow-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" />
|
||||
@@ -252,6 +281,8 @@ export function CalendarPage() {
|
||||
eventResizableFromStart
|
||||
eventDurationEditable
|
||||
eventStartEditable
|
||||
slotEventOverlap={false}
|
||||
eventMinHeight={28}
|
||||
dayMaxEvents={isMobile ? 2 : 3}
|
||||
eventTimeFormat={{
|
||||
hour: "2-digit",
|
||||
@@ -297,17 +328,37 @@ 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 client = a.client as (NonNullable<Appointment["client"]> & {
|
||||
notes?: string | null;
|
||||
no_show_count?: number;
|
||||
}) | undefined;
|
||||
const risky =
|
||||
(client?.no_show_count ?? 0) >= 2 || !!client?.notes?.startsWith("[Riesgo");
|
||||
const isTime = arg.view.type !== "dayGridMonth";
|
||||
// When the event is rendered in a narrow column (because FullCalendar
|
||||
// splits overlapping events side-by-side) we drop the employee line so
|
||||
// client + service still fit without bleeding into adjacent slots.
|
||||
const isNarrow = arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay";
|
||||
const dur = a.service?.duration_min;
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-0.5 overflow-hidden">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex h-full min-h-0 flex-col gap-0.5 overflow-hidden">
|
||||
<div className="flex items-center gap-1 leading-none">
|
||||
<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>
|
||||
<span className="truncate 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 className="flex items-center gap-1">
|
||||
<span className="truncate font-bold leading-tight">{a.client?.name ?? "Cliente"}</span>
|
||||
{risky && (
|
||||
<span title="Ausencias previas sin avisar" aria-label="Ausencias previas sin avisar">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[10px] leading-tight opacity-80">
|
||||
{a.service?.name}{dur ? ` (${dur} min)` : ""}
|
||||
</div>
|
||||
{isTime && a.employee && !isNarrow && (
|
||||
<div className="mt-auto truncate text-[10px] leading-tight opacity-70">con {a.employee.name}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Flame,
|
||||
Trophy,
|
||||
Coins,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
@@ -50,8 +51,8 @@ export function DashboardPage() {
|
||||
const symbol = business?.currency_symbol ?? "$";
|
||||
|
||||
const { data: overview, isLoading: ovLoading } = useQuery({
|
||||
queryKey: ["dashboard", "overview"],
|
||||
queryFn: () => api.dashboard.overview(),
|
||||
queryKey: ["dashboard", "overview", range],
|
||||
queryFn: () => api.dashboard.overview(Number(range)),
|
||||
});
|
||||
const { data: emp } = useQuery({
|
||||
queryKey: ["dashboard", "best-employees", range],
|
||||
@@ -118,13 +119,20 @@ export function DashboardPage() {
|
||||
|
||||
<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">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<KpiCard
|
||||
title="Ingresos de hoy"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.revenue_today ?? 0, symbol)}
|
||||
sub="día de hoy"
|
||||
icon={Wallet}
|
||||
color="#10b981"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ingresos del período"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.revenue_month ?? 0, symbol)}
|
||||
sub="últimos 30 días"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.revenue_range ?? 0, symbol)}
|
||||
sub={`últimos ${range} días`}
|
||||
delta={overview?.revenue_change_pct}
|
||||
deltaSuffix="% vs mes anterior"
|
||||
deltaSuffix="% vs período anterior"
|
||||
icon={DollarSign}
|
||||
color="#3b66ff"
|
||||
/>
|
||||
@@ -133,7 +141,7 @@ export function DashboardPage() {
|
||||
value={ovLoading ? "…" : formatNumber(overview?.appts_upcoming ?? 0)}
|
||||
sub="próximas"
|
||||
icon={CalendarCheck}
|
||||
color="#10b981"
|
||||
color="#6366f1"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ticket promedio"
|
||||
@@ -143,8 +151,8 @@ export function DashboardPage() {
|
||||
color="#a855f7"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ocupación"
|
||||
value={ovLoading ? "…" : `${overview?.occupancy_pct ?? 0}%`}
|
||||
title="Finalización"
|
||||
value={ovLoading ? "…" : `${overview?.completion_pct ?? 0}%`}
|
||||
sub={`${overview?.cancel_rate ?? 0}% cancelación`}
|
||||
icon={Flame}
|
||||
color="#f17616"
|
||||
|
||||
@@ -130,9 +130,9 @@ export function EmployeesPage() {
|
||||
)}
|
||||
<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}%
|
||||
% de citas {e.stats?.share_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 className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.share_pct ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+64
-51
@@ -5,11 +5,13 @@ import { useAuth } from "../lib/auth";
|
||||
import { api } from "../lib/api";
|
||||
import { Spinner } from "../components/ui";
|
||||
|
||||
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
||||
|
||||
export function LoginPage() {
|
||||
const { login, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("[email protected]");
|
||||
const [password, setPassword] = useState("demo1234");
|
||||
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
|
||||
const [password, setPassword] = useState(DEMO ? "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 }[]>([]);
|
||||
@@ -19,6 +21,7 @@ export function LoginPage() {
|
||||
}, [user, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!DEMO) return;
|
||||
api.auth.demoUsers().then((r) => setDemoUsers(r.users)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -36,20 +39,24 @@ export function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
// Demo-only quick login. Dead-code-eliminated from the prod bundle (DEMO is a
|
||||
// build-time constant), which is why the demo password lives only inside this branch.
|
||||
const quickLogin = DEMO
|
||||
? 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);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen lg:grid-cols-2">
|
||||
@@ -61,7 +68,7 @@ export function LoginPage() {
|
||||
<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>
|
||||
<span className="text-lg font-extrabold tracking-tight">AgendaMax</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-md">
|
||||
@@ -87,7 +94,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-xs text-brand-200">
|
||||
© {new Date().getFullYear()} AgendaPro · Demo
|
||||
© {new Date().getFullYear()} AgendaMax · Demo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +105,7 @@ export function LoginPage() {
|
||||
<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>
|
||||
<h1 className="text-xl font-extrabold">AgendaMax</h1>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-card">
|
||||
@@ -149,41 +156,47 @@ export function LoginPage() {
|
||||
</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>
|
||||
{DEMO && (
|
||||
<>
|
||||
<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 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>
|
||||
{DEMO && (
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CalendarCheck,
|
||||
Clock,
|
||||
Wallet,
|
||||
Star,
|
||||
Coins,
|
||||
CalendarClock,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { PageHeader, Spinner, EmptyState } from "../components/ui";
|
||||
import { formatCurrency, formatTime, formatDate } from "../lib/format";
|
||||
|
||||
export function MyPerformancePage() {
|
||||
const { business, user } = useAuth();
|
||||
const symbol = business?.currency_symbol ?? "$";
|
||||
|
||||
const { data: perf, isLoading } = useQuery({
|
||||
queryKey: ["me", "performance"],
|
||||
queryFn: () => api.me.performance(),
|
||||
});
|
||||
const { data: commissions } = useQuery({
|
||||
queryKey: ["me", "commissions"],
|
||||
queryFn: () => api.me.commissions(),
|
||||
});
|
||||
|
||||
const next = perf?.next_appointment ?? null;
|
||||
const commissionRows = commissions?.rows ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Mi desempeño"
|
||||
subtitle={user?.name ? `Hola, ${user.name.split(" ")[0]}` : "Tu actividad y comisiones"}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<StatCard
|
||||
title="Citas hoy"
|
||||
value={isLoading ? "…" : String(perf?.appointments_today ?? 0)}
|
||||
sub="día de hoy"
|
||||
icon={CalendarCheck}
|
||||
color="#3b66ff"
|
||||
/>
|
||||
<StatCard
|
||||
title="Próximas"
|
||||
value={isLoading ? "…" : String(perf?.upcoming ?? 0)}
|
||||
sub="programadas"
|
||||
icon={Clock}
|
||||
color="#6366f1"
|
||||
/>
|
||||
<StatCard
|
||||
title="Ingresos (30 días)"
|
||||
value={isLoading ? "…" : formatCurrency(perf?.revenue_30d ?? 0, symbol)}
|
||||
sub="tus ventas"
|
||||
icon={Wallet}
|
||||
color="#10b981"
|
||||
/>
|
||||
<StatCard
|
||||
title="Valoración"
|
||||
value={isLoading ? "…" : `★ ${perf?.avg_rating ?? 0}`}
|
||||
sub={`${perf?.share_pct ?? 0}% de las citas del negocio`}
|
||||
icon={Star}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Próxima cita + Comisiones total */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
{/* Próxima cita */}
|
||||
<div className="card p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4 text-brand-600" />
|
||||
<h3 className="text-sm font-bold text-slate-900">Próxima cita</h3>
|
||||
</div>
|
||||
{!next ? (
|
||||
<EmptyState title="Sin citas próximas" description="No tienes citas programadas por ahora." />
|
||||
) : (
|
||||
<div className="rounded-xl bg-brand-50/60 p-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-extrabold tracking-tight text-brand-700">
|
||||
{formatTime(next.start_at)}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-brand-500">
|
||||
{formatDate(next.start_at, { weekday: "long", day: "numeric", month: "short" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-sm">
|
||||
<div className="font-bold text-slate-900">{next.service_name}</div>
|
||||
<div className="text-slate-500">{next.client_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comisiones list */}
|
||||
<div className="card overflow-hidden lg:col-span-2">
|
||||
<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">Mis comisiones (30 días)</h3>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Total de comisiones
|
||||
</div>
|
||||
<div className="text-lg font-extrabold text-emerald-700">
|
||||
{formatCurrency(commissions?.total ?? 0, symbol)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!commissions ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : commissionRows.length === 0 ? (
|
||||
<EmptyState title="Sin comisiones" description="Aún no tienes ventas en los últimos 30 días." />
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile cards */}
|
||||
<div className="divide-y divide-slate-50 sm:hidden">
|
||||
{commissionRows.slice(0, 20).map((r, i) => (
|
||||
<div key={`${r.appointment_id ?? "t"}-${i}`} className="flex items-center gap-2 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{r.service_name}</div>
|
||||
<div className="truncate text-[11px] text-slate-500">
|
||||
{r.client_name} · {formatDate(r.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm font-extrabold text-emerald-600">
|
||||
{formatCurrency(r.commission, symbol)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400">{formatCurrency(r.amount, symbol)}</div>
|
||||
</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-4 py-2">Servicio</th>
|
||||
<th className="px-4 py-2">Cliente</th>
|
||||
<th className="px-4 py-2">Fecha</th>
|
||||
<th className="px-4 py-2 text-right">Venta</th>
|
||||
<th className="px-4 py-2 text-right">Comisión</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{commissionRows.slice(0, 50).map((r, i) => (
|
||||
<tr key={`${r.appointment_id ?? "t"}-${i}`} className="border-t border-slate-50">
|
||||
<td className="px-4 py-2 font-semibold text-slate-800">{r.service_name}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{r.client_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-slate-500">{formatDate(r.created_at)}</td>
|
||||
<td className="px-4 py-2 text-right text-slate-600">{formatCurrency(r.amount, symbol)}</td>
|
||||
<td className="px-4 py-2 text-right font-bold text-emerald-600">
|
||||
{formatCurrency(r.commission, symbol)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
sub,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: 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">{title}</div>
|
||||
<div className="mt-1 text-2xl font-extrabold tracking-tight text-slate-900">{value}</div>
|
||||
{sub && <div className="mt-0.5 truncate 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>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function AdminOverviewPage() {
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Resumen de la plataforma"
|
||||
subtitle="Visión general de todos los negocios en AgendaPro"
|
||||
subtitle="Visión general de todos los negocios en AgendaMax"
|
||||
actions={
|
||||
<Link to="/admin/businesses?new=1" className="btn btn-primary">
|
||||
<Plus className="h-4 w-4" /> Nuevo negocio
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Sparkles,
|
||||
Clock,
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
PartyPopper,
|
||||
AlertCircle,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getBusiness,
|
||||
@@ -64,6 +66,9 @@ export default function BookingPage() {
|
||||
const [selectedSlot, setSelectedSlot] = useState<PublicSlot | null>(null);
|
||||
const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" });
|
||||
const [result, setResult] = useState<BookResponse | null>(null);
|
||||
const [bookError, setBookError] = useState<string | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const businessQuery = useQuery({
|
||||
queryKey: ["public", "business", slug],
|
||||
@@ -98,9 +103,16 @@ export default function BookingPage() {
|
||||
const bookMut = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof book>[1]) => book(slug!, payload),
|
||||
onSuccess: (res) => {
|
||||
setBookError(null);
|
||||
setResult(res);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setBookError(err?.message || "No pudimos completar tu reserva. Inténtalo de nuevo.");
|
||||
setResult(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,6 +127,7 @@ export default function BookingPage() {
|
||||
setSelectedSlot(null);
|
||||
setClient({ name: "", phone: "", email: "", notes: "" });
|
||||
setResult(null);
|
||||
setBookError(null);
|
||||
bookMut.reset();
|
||||
};
|
||||
|
||||
@@ -142,8 +155,28 @@ export default function BookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasPolicy =
|
||||
(business.cancel_penalty_pct ?? 0) > 0 || !!business.require_deposit;
|
||||
const policyNote = (
|
||||
<PolicyNote
|
||||
cancelWindowHours={business.cancel_window_hours}
|
||||
cancelPenaltyPct={business.cancel_penalty_pct}
|
||||
requireDeposit={business.require_deposit}
|
||||
depositPct={business.deposit_pct}
|
||||
/>
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return <Confirmation result={result} businessName={business.name} onReset={reset} />;
|
||||
return (
|
||||
<Confirmation
|
||||
result={result}
|
||||
businessName={business.name}
|
||||
clientName={client.name.trim()}
|
||||
hasPolicy={hasPolicy}
|
||||
policy={policyNote}
|
||||
onReset={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!business.booking_enabled) {
|
||||
@@ -177,12 +210,14 @@ export default function BookingPage() {
|
||||
if (serviceId !== id) {
|
||||
setServiceId(id);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pickEmployee = (id: number | null) => {
|
||||
setEmployeeId(id);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
};
|
||||
|
||||
const pickSlot = (slot: PublicSlot) => {
|
||||
@@ -193,6 +228,7 @@ export default function BookingPage() {
|
||||
const handleBook = () => {
|
||||
if (!selectedService || !selectedSlot) return;
|
||||
if (!client.name.trim()) return;
|
||||
setBookError(null);
|
||||
bookMut.mutate({
|
||||
service_id: selectedService.id,
|
||||
employee_id: autoAssign ? undefined : selectedSlot.employee_id,
|
||||
@@ -267,6 +303,7 @@ export default function BookingPage() {
|
||||
onDate={(d) => {
|
||||
setSelectedDate(d);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
}}
|
||||
min={todayStr()}
|
||||
max={maxBookableDate()}
|
||||
@@ -285,9 +322,30 @@ export default function BookingPage() {
|
||||
title="Tus datos"
|
||||
subtitle="Necesitamos algunos datos para confirmar tu cita."
|
||||
>
|
||||
{bookError && (
|
||||
<div className="mb-4 flex items-start gap-3 rounded-2xl bg-rose-600 px-4 py-3 text-white shadow-soft animate-slide-up">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-snug">{bookError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBookError(null);
|
||||
setSelectedSlot(null);
|
||||
setStep(steps.find((s) => s.original === 3)?.n ?? 3);
|
||||
queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] });
|
||||
}}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-white/20 px-2.5 py-1 text-xs font-bold backdrop-blur transition-colors hover:bg-white/30"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DetailsForm
|
||||
client={client}
|
||||
setClient={setClient}
|
||||
policy={policyNote}
|
||||
summary={
|
||||
<>
|
||||
<SummaryRow label="Servicio" value={selectedService.name} />
|
||||
@@ -705,15 +763,17 @@ function DetailsForm({
|
||||
client,
|
||||
setClient,
|
||||
summary,
|
||||
policy,
|
||||
}: {
|
||||
client: { name: string; phone: string; email: string; notes: string };
|
||||
setClient: (c: { name: string; phone: string; email: string; notes: string }) => void;
|
||||
summary: React.ReactNode;
|
||||
policy?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card divide-y divide-slate-100 p-2">{summary}</div>
|
||||
|
||||
{policy}
|
||||
<div className="card p-4">
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
@@ -773,6 +833,37 @@ function DetailsForm({
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyNote({
|
||||
cancelWindowHours,
|
||||
cancelPenaltyPct,
|
||||
requireDeposit,
|
||||
depositPct,
|
||||
}: {
|
||||
cancelWindowHours: number;
|
||||
cancelPenaltyPct: number;
|
||||
requireDeposit: boolean | number;
|
||||
depositPct: number;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
if ((cancelPenaltyPct ?? 0) > 0) {
|
||||
lines.push(`Cancela con al menos ${cancelWindowHours ?? 0} h de anticipación. Si no, aplica un cargo del ${cancelPenaltyPct}%.`);
|
||||
}
|
||||
if (requireDeposit) {
|
||||
lines.push(`Se solicita un depósito del ${depositPct ?? 0}% (te lo indicará el negocio al pagar).`);
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-xl bg-amber-50 px-3 py-2.5 text-xs text-amber-800">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<div className="space-y-0.5">
|
||||
{lines.map((l) => (
|
||||
<p key={l}>{l}</p>
|
||||
))}
|
||||
</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">
|
||||
@@ -846,7 +937,7 @@ function ActionBar({
|
||||
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
|
||||
Reserva en línea · {name} · powered by AgendaMax
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -854,14 +945,21 @@ function FooterNote({ name }: { name: string }) {
|
||||
function Confirmation({
|
||||
result,
|
||||
businessName,
|
||||
clientName,
|
||||
hasPolicy,
|
||||
policy,
|
||||
onReset,
|
||||
}: {
|
||||
result: BookResponse;
|
||||
businessName: string;
|
||||
clientName: string;
|
||||
hasPolicy: boolean;
|
||||
policy?: React.ReactNode;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const { appointment } = result;
|
||||
const currency = result.business.currency_symbol || "$";
|
||||
const showRisk = result.risk_flag === true || (result.no_show_count ?? 0) >= 2;
|
||||
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} stepCount={4} steps={[]} />
|
||||
@@ -879,6 +977,14 @@ function Confirmation({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-slate-100 bg-slate-50/60 px-5 py-3 text-center">
|
||||
<p className="text-sm font-bold text-slate-800">
|
||||
{result.client_created === false
|
||||
? `¡Gracias por volver, ${clientName}! 👋`
|
||||
: `¡Hola, ${clientName}! Tu cita quedó confirmada.`}
|
||||
</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" />
|
||||
@@ -908,6 +1014,16 @@ function Confirmation({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRisk && (
|
||||
<div className="mt-4 flex items-start gap-2.5 rounded-2xl bg-amber-50 px-4 py-3 text-amber-800 ring-1 ring-amber-200">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<p className="text-xs font-medium leading-relaxed">
|
||||
Notamos ausencias previas sin avisar. Te enviaremos un recordatorio; reserva y no asistir puede generar un cargo según la política del negocio.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasPolicy && policy ? <div className="mt-4">{policy}</div> : null}
|
||||
|
||||
<button onClick={onReset} className="btn btn-secondary mt-4 w-full" type="button">
|
||||
<ChevronLeft className="h-4 w-4" /> Reservar otra cita
|
||||
</button>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user