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:
+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>
|
||||
|
||||
Reference in New Issue
Block a user