mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(classes): session roster + manual waitlist promotion
Adds the roster and manual promotion controls deferred from the booking
lifecycle milestones. Businesses can now inspect who holds a seat and who
is queueing on a class session, and promote waitlisted customers by hand
— including a deliberate over-capacity override when the session is full.
Backend:
- GET /v1/catalog/class-sessions/{id}/roster — session metadata plus
confirmed and waitlist entry lists with queue positions
- POST /v1/catalog/bookings/{id}/promote — 409 on a full session unless
allowOverCapacity is set; 400 for non-waitlisted, 404 for missing or
foreign bookings; reuses the shared confirm path so waitlist ledger
rows resolve and reminder jobs re-arm
- ListBookingsBySession repository method (pgx + memory); ClassSession
carries waitlistCount; UpcomingBooking carries classSessionId and
waitlistPosition
- OpenAPI paths + schemas, regenerated client, catalog service tests
Frontend:
- classes tab session rows expand into a two-column roster panel with
per-entry promote buttons and an explicit confirm-anyway step on 409
- booking detail modal shows waitlist position and offers promote for
waitlisted bookings; list badges show #N queue positions
- CS/EN i18n keys and seeded demo roster data; demo mutations surface
the demo notice
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
a82a48e1c8
commit
1cd5b1ba91
18 files changed
+1022
-59
No files matched your search
@@ -96,6 +96,12 @@ export const ChevronRightIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronDownIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SparklesIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||
|
||||
@@ -424,6 +424,23 @@ const dictionaries = {
|
||||
"dashboard.class.noTemplates": "Zatím žádné šablony lekcí.",
|
||||
"dashboard.class.noSessions": "Zatím žádné naplánované lekce.",
|
||||
"dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.",
|
||||
"dashboard.class.roster": "Prezenční listina",
|
||||
"dashboard.class.attendees": "Potvrzení účastníci",
|
||||
"dashboard.class.waitlist": "Čekací listina",
|
||||
"dashboard.class.waiting": "čeká",
|
||||
"dashboard.class.noAttendees": "Zatím žádní účastníci.",
|
||||
"dashboard.class.noWaitlist": "Čekací listina je prázdná.",
|
||||
"dashboard.class.promote": "Potvrdit místo",
|
||||
"dashboard.class.promoteFull": "Lekce je plná — přesto potvrdit?",
|
||||
"dashboard.class.promoteConfirm": "Přesto potvrdit",
|
||||
"dashboard.class.promoted": "Rezervace byla potvrzena.",
|
||||
"dashboard.class.promoteFailed": "Potvrzení rezervace selhalo.",
|
||||
"dashboard.class.queuePosition": "v pořadí",
|
||||
"dashboard.class.showRoster": "Účastníci",
|
||||
"dashboard.class.hideRoster": "Skrýt",
|
||||
"dashboard.waitlistPosition": "Pořadí v čekací listině",
|
||||
"dashboard.booking.promote": "Potvrdit z čekací listiny",
|
||||
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
|
||||
"dashboard.services": "Služby",
|
||||
"dashboard.services.subtitle": "služeb v nabídce",
|
||||
"dashboard.service.add": "Přidat službu",
|
||||
@@ -1002,6 +1019,23 @@ const dictionaries = {
|
||||
"dashboard.class.noTemplates": "No class templates yet.",
|
||||
"dashboard.class.noSessions": "No scheduled sessions yet.",
|
||||
"dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.",
|
||||
"dashboard.class.roster": "Session roster",
|
||||
"dashboard.class.attendees": "Confirmed attendees",
|
||||
"dashboard.class.waitlist": "Waitlist",
|
||||
"dashboard.class.waiting": "waiting",
|
||||
"dashboard.class.noAttendees": "No attendees yet.",
|
||||
"dashboard.class.noWaitlist": "The waitlist is empty.",
|
||||
"dashboard.class.promote": "Confirm seat",
|
||||
"dashboard.class.promoteFull": "Session is full — confirm anyway?",
|
||||
"dashboard.class.promoteConfirm": "Confirm anyway",
|
||||
"dashboard.class.promoted": "Booking confirmed.",
|
||||
"dashboard.class.promoteFailed": "Failed to confirm the booking.",
|
||||
"dashboard.class.queuePosition": "in queue",
|
||||
"dashboard.class.showRoster": "Roster",
|
||||
"dashboard.class.hideRoster": "Hide",
|
||||
"dashboard.waitlistPosition": "Waitlist position",
|
||||
"dashboard.booking.promote": "Promote from waitlist",
|
||||
"dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?",
|
||||
"dashboard.services": "Services",
|
||||
"dashboard.services.subtitle": "services offered",
|
||||
"dashboard.service.add": "Add service",
|
||||
|
||||
@@ -226,6 +226,7 @@ function BookingsPage() {
|
||||
: "bg-canvas-subtle text-ink-subtle"
|
||||
}`}>
|
||||
{statusLabels[booking.status]}
|
||||
{booking.status === "waitlisted" && booking.waitlistPosition ? ` #${booking.waitlistPosition}` : ""}
|
||||
</span>
|
||||
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
|
||||
<EyeIcon />
|
||||
|
||||
@@ -48,7 +48,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
||||
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
|
||||
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
|
||||
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
|
||||
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
|
||||
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", waitlistPosition: 1, startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createSignal, type JSX, type ParentComponent } from "solid-js";
|
||||
import { Show, createEffect, createSignal, type JSX, type ParentComponent } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { useTheme } from "../../providers/theme-provider";
|
||||
import { BookraCharacter } from "../../components/bookra-character";
|
||||
@@ -32,6 +32,15 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
const location = useLocation();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
|
||||
const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
|
||||
// "armed" means the session reported full and the next click confirms anyway.
|
||||
const [promoteConfirm, setPromoteConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
|
||||
|
||||
// Reset two-step confirmations whenever a different booking opens.
|
||||
createEffect(() => {
|
||||
data.selectedBooking()?.id;
|
||||
setCancelConfirm("idle");
|
||||
setPromoteConfirm("idle");
|
||||
});
|
||||
|
||||
const handleCancelBooking = async () => {
|
||||
if (cancelConfirm() === "busy") return;
|
||||
@@ -57,6 +66,39 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteBooking = async () => {
|
||||
if (promoteConfirm() === "busy") return;
|
||||
const booking = data.selectedBooking();
|
||||
const bearer = data.token();
|
||||
if (!booking || booking.status !== "waitlisted") return;
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze měnit rezervace." : "Cannot modify bookings in demo mode.");
|
||||
setPromoteConfirm("idle");
|
||||
return;
|
||||
}
|
||||
const allowOverCapacity = promoteConfirm() === "armed";
|
||||
setPromoteConfirm("busy");
|
||||
try {
|
||||
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/promote`, {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: { allowOverCapacity },
|
||||
});
|
||||
if (response.error) {
|
||||
if (response.response?.status === 409 && !allowOverCapacity) {
|
||||
setPromoteConfirm("armed");
|
||||
return;
|
||||
}
|
||||
throw new Error("promote failed");
|
||||
}
|
||||
data.setShowBookingDetail(false);
|
||||
void data.refetchSummary();
|
||||
} catch {
|
||||
data.setDemoNotice(data.i18n.t("dashboard.booking.promoteFailed"));
|
||||
} finally {
|
||||
if (promoteConfirm() === "busy") setPromoteConfirm("idle");
|
||||
}
|
||||
};
|
||||
|
||||
const activeSection = () => {
|
||||
const p = location.pathname;
|
||||
const exact = navItems.find(item => p === item.path);
|
||||
@@ -275,6 +317,12 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
{data.i18n.t(`dashboard.${data.selectedBooking()?.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={data.selectedBooking()?.status === "waitlisted" && data.selectedBooking()?.waitlistPosition}>
|
||||
<div class="flex items-center justify-between p-4 bg-warning-subtle/50 rounded-xl">
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider">{data.i18n.t("dashboard.waitlistPosition")}</p>
|
||||
<p class="font-semibold text-warning">#{data.selectedBooking()?.waitlistPosition}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={data.selectedBooking()?.notes}>
|
||||
<div class="p-4 bg-canvas-subtle rounded-xl">
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.notes")}</p>
|
||||
@@ -283,7 +331,24 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
||||
<div class="p-6 border-t border-border">
|
||||
<div class="p-6 border-t border-border space-y-3">
|
||||
<Show when={data.selectedBooking()?.status === "waitlisted"}>
|
||||
<button
|
||||
onClick={() => void handlePromoteBooking()}
|
||||
disabled={promoteConfirm() === "busy"}
|
||||
class={`w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-colors disabled:opacity-60 ${
|
||||
promoteConfirm() === "armed"
|
||||
? "bg-warning text-canvas hover:opacity-90"
|
||||
: "bg-accent text-canvas hover:bg-accent-hover"
|
||||
}`}
|
||||
>
|
||||
{promoteConfirm() === "busy"
|
||||
? "…"
|
||||
: promoteConfirm() === "armed"
|
||||
? data.i18n.t("dashboard.booking.promoteFull")
|
||||
: data.i18n.t("dashboard.booking.promote")}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => void handleCancelBooking()}
|
||||
disabled={cancelConfirm() === "busy"}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { For, Show, createResource, createSignal } from "solid-js";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons";
|
||||
import { getInitials } from "../../components/dashboard/types";
|
||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon } from "../../components/dashboard/icons";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function ZonesPage() {
|
||||
@@ -29,6 +30,11 @@ function ZonesPage() {
|
||||
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
||||
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
||||
const [sessionSaving, setSessionSaving] = createSignal(false);
|
||||
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
|
||||
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
|
||||
// Booking IDs whose promote attempt hit a full session — awaiting the
|
||||
// explicit "confirm anyway" over-capacity resubmit.
|
||||
const [promoteArmed, setPromoteArmed] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
||||
|
||||
@@ -118,13 +124,74 @@ function ZonesPage() {
|
||||
if (!bearer || tab !== "classes") return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
const start = new Date(Date.now() + 3 * 86400000);
|
||||
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3 }];
|
||||
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3, waitlistCount: 2 }];
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders());
|
||||
return (response.data as any[]) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
const [roster, { refetch: refetchRoster }] = createResource(
|
||||
() => [data.token(), expandedSessionId()] as const,
|
||||
async ([bearer, sessionId]) => {
|
||||
if (!bearer || !sessionId) return null;
|
||||
if (bearer.startsWith("demo.")) {
|
||||
const start = new Date(Date.now() + 3 * 86400000);
|
||||
const mk = (id: string, name: string, email: string, status: string, pos = 0) => ({
|
||||
bookingId: id, reference: `BOK-${id.slice(-3).toUpperCase()}`, customerName: name, customerEmail: email,
|
||||
status, waitlistPosition: pos, createdAt: start.toISOString(),
|
||||
});
|
||||
return {
|
||||
sessionId,
|
||||
templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga",
|
||||
locationName: cs() ? "Hlavní sál" : "Main Hall",
|
||||
startsAt: start.toISOString(),
|
||||
endsAt: new Date(start.getTime() + 3600000).toISOString(),
|
||||
capacity: 8,
|
||||
confirmed: [
|
||||
mk("roster-001", cs() ? "Martina Nováková" : "Martina Novakova", "[email protected]", "confirmed"),
|
||||
mk("roster-002", "David Svoboda", "[email protected]", "confirmed"),
|
||||
mk("roster-003", "Alice Johnson", "[email protected]", "confirmed"),
|
||||
],
|
||||
waitlist: [
|
||||
mk("roster-004", cs() ? "Jana Kovářová" : "Jana Kovarova", "[email protected]", "waitlisted", 1),
|
||||
mk("roster-005", "Bob Smith", "[email protected]", "waitlisted", 2),
|
||||
],
|
||||
};
|
||||
}
|
||||
const response = await (apiClient as any).GET(`/v1/catalog/class-sessions/${sessionId}/roster`, authHeaders());
|
||||
return response.data ?? null;
|
||||
},
|
||||
);
|
||||
|
||||
const handlePromote = async (bookingId: string, allowOverCapacity: boolean) => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
data.setDemoNotice(cs() ? "V demo režimu nelze měnit rezervace." : "Cannot modify bookings in demo mode.");
|
||||
return;
|
||||
}
|
||||
setPromoteBusy(bookingId);
|
||||
try {
|
||||
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${bookingId}/promote`, {
|
||||
...authHeaders(),
|
||||
body: { allowOverCapacity },
|
||||
});
|
||||
if (response.error) {
|
||||
if (response.response?.status === 409 && !allowOverCapacity) {
|
||||
setPromoteArmed((prev) => new Set(prev).add(bookingId));
|
||||
return;
|
||||
}
|
||||
throw new Error("promote failed");
|
||||
}
|
||||
setPromoteArmed((prev) => { const next = new Set(prev); next.delete(bookingId); return next; });
|
||||
void refetchRoster(); void refetchSessions(); void data.refetchSummary();
|
||||
} catch {
|
||||
data.setDemoNotice(data.i18n.t("dashboard.class.promoteFailed"));
|
||||
} finally {
|
||||
setPromoteBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedLocations = () => locations() ?? [];
|
||||
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
|
||||
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
|
||||
@@ -579,19 +646,121 @@ function ZonesPage() {
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={classSessions() ?? []}>
|
||||
{(session: any) => (
|
||||
<div class="flex items-center justify-between p-4 px-6">
|
||||
{(session: any) => {
|
||||
const expanded = () => expandedSessionId() === session.id;
|
||||
return (
|
||||
<div>
|
||||
<p class="font-medium text-ink">{session.templateTitle}</p>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||
{session.locationName ? ` • ${session.locationName}` : ""}
|
||||
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-3 p-4 px-6">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="font-medium text-ink">{session.templateTitle}</p>
|
||||
<Show when={session.waitlistCount > 0}>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-warning-subtle text-warning">
|
||||
{session.waitlistCount} {data.i18n.t("dashboard.class.waiting")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||
{session.locationName ? ` • ${session.locationName}` : ""}
|
||||
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => setExpandedSessionId(expanded() ? null : session.id)}
|
||||
class={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${expanded() ? "bg-accent-subtle text-accent" : "text-ink-muted hover:text-ink hover:bg-canvas-subtle"}`}
|
||||
>
|
||||
{expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")}
|
||||
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
|
||||
</button>
|
||||
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<div class="px-6 pb-5 animate-fade-in">
|
||||
<div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden">
|
||||
<Show when={!roster.loading} fallback={
|
||||
<div class="p-8 text-center text-sm text-ink-muted animate-pulse">…</div>
|
||||
}>
|
||||
<div class="grid sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-border/60">
|
||||
<div class="p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
|
||||
{data.i18n.t("dashboard.class.attendees")} ({(roster()?.confirmed ?? []).length}/{roster()?.capacity ?? session.capacity})
|
||||
</p>
|
||||
<Show when={(roster()?.confirmed ?? []).length > 0} fallback={
|
||||
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noAttendees")}</p>
|
||||
}>
|
||||
<div class="space-y-2">
|
||||
<For each={roster()?.confirmed ?? []}>
|
||||
{(entry: any) => (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-[11px] font-bold text-accent shrink-0">
|
||||
{getInitials(entry.customerName)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
|
||||
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
|
||||
{data.i18n.t("dashboard.class.waitlist")} ({(roster()?.waitlist ?? []).length})
|
||||
</p>
|
||||
<Show when={(roster()?.waitlist ?? []).length > 0} fallback={
|
||||
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noWaitlist")}</p>
|
||||
}>
|
||||
<div class="space-y-2">
|
||||
<For each={roster()?.waitlist ?? []}>
|
||||
{(entry: any) => (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-warning-subtle flex items-center justify-center text-[11px] font-bold text-warning shrink-0">
|
||||
#{entry.waitlistPosition}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
|
||||
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
|
||||
</div>
|
||||
<Show
|
||||
when={!promoteArmed().has(entry.bookingId)}
|
||||
fallback={
|
||||
<button
|
||||
onClick={() => void handlePromote(entry.bookingId, true)}
|
||||
disabled={promoteBusy() === entry.bookingId}
|
||||
title={data.i18n.t("dashboard.class.promoteFull")}
|
||||
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-warning text-canvas hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||
>
|
||||
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promoteConfirm")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => void handlePromote(entry.bookingId, false)}
|
||||
disabled={promoteBusy() === entry.bookingId}
|
||||
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-accent text-canvas hover:bg-accent-hover transition-colors disabled:opacity-60"
|
||||
>
|
||||
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promote")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
Reference in new issue
Block a user