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:
Tomas DvorakandDevin committed 2026-09-18 14:37:40 +02:00
1 parent a82a48e1c8
commit 1cd5b1ba91
18 files changed
+1022 -59

No files matched your search

+182 -13
View File
@@ -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>