mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 07:54:44 +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
@@ -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