feat(bookings): session-to-session move for class bookings

Class customers can move a booking to another future session of the
same template from the manage page. Full target sessions place the
booking on the waitlist; freeing a confirmed seat drains the old
session's queue via the existing promotion path. Reminders re-arm
for the new time.

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 15:42:04 +02:00
1 parent 6c3b2bf6bf
commit e5cb9bd543
10 files changed
+927 -10

No files matched your search

@@ -11,9 +11,12 @@ export function BookingManageRoute() {
const i18n = useI18n();
const [isRescheduling, setIsRescheduling] = createSignal(false);
const [isCancelling, setIsCancelling] = createSignal(false);
const [isMoving, setIsMoving] = createSignal(false);
const [newDate, setNewDate] = createSignal("");
const [selectedSlot, setSelectedSlot] = createSignal<{ startsAt: string; endsAt: string } | null>(null);
const [selectedSession, setSelectedSession] = createSignal<string | null>(null);
const [rescheduleBusy, setRescheduleBusy] = createSignal(false);
const [moveBusy, setMoveBusy] = createSignal(false);
const [message, setMessage] = createSignal("");
const [actionSuccess, setActionSuccess] = createSignal<string | null>(null);
const [actionError, setActionError] = createSignal<string | null>(null);
@@ -67,6 +70,61 @@ export function BookingManageRoute() {
},
);
const [moveTargets] = createResource(
() => [isMoving(), token(), reference()] as const,
async ([open, t, ref]) => {
if (!open || !t || !ref) return [];
try {
const response = await (apiClient as any).GET(`/v1/public/bookings/${ref}/sessions`, {
params: { query: { token: t } },
});
return (response.data?.sessions ?? []) as any[];
} catch {
return [];
}
},
);
const handleMove = async () => {
setActionSuccess(null);
setActionError(null);
const sessionId = selectedSession();
const ref = reference();
const t = token();
if (!sessionId || !ref || !t) {
setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím jinou lekci' : 'Please pick another session');
return;
}
setMoveBusy(true);
try {
const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/move`, {
params: { query: { token: t } },
body: { classSessionId: sessionId },
});
if (response.error) {
setActionError(i18n.locale() === 'cs' ? 'Přesun na jinou lekci selhal' : 'Moving to another session failed');
return;
}
const waitlisted = (response.data as any)?.status === 'waitlisted';
setActionSuccess(
waitlisted
? (i18n.locale() === 'cs' ? 'Lekce je plná — byli jste zařazeni na čekací listinu' : 'Session is full — you were added to its waitlist')
: (i18n.locale() === 'cs' ? 'Rezervace byla přesunuta na jinou lekci' : 'Booking moved to another session')
);
setIsMoving(false);
setSelectedSession(null);
void refetchBooking();
} catch {
setActionError(i18n.locale() === 'cs' ? 'Přesun na jinou lekci selhal' : 'Moving to another session failed');
} finally {
setMoveBusy(false);
}
};
const handleReschedule = async () => {
setActionSuccess(null);
setActionError(null);
@@ -353,6 +411,21 @@ export function BookingManageRoute() {
{i18n.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
</Button>
</Show>
<Show when={b().classSessionId}>
<Button
variant="secondary"
class="flex-1"
onClick={() => setIsMoving(true)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<path d="M8 3 4 7l4 4"/>
<path d="M4 7h16"/>
<path d="m16 21 4-4-4-4"/>
<path d="M20 17H4"/>
</svg>
{i18n.locale() === 'cs' ? 'Změnit lekci' : 'Change session'}
</Button>
</Show>
<Button
variant="secondary"
class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]"
@@ -366,13 +439,6 @@ export function BookingManageRoute() {
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
</Button>
</div>
<Show when={b().classSessionId}>
<p class="text-sm text-ink-muted pt-1">
{i18n.locale() === 'cs'
? 'Termín lekce je pevně daný. Pro jiný termín rezervaci zrušte a objednejte jinou lekci.'
: 'Class times are fixed. To switch sessions, cancel this booking and book another class.'}
</p>
</Show>
</Show>
</CardContent>
</Card>
@@ -500,6 +566,82 @@ export function BookingManageRoute() {
</DialogContent>
</Dialog>
{/* Move Session Dialog */}
<Dialog open={isMoving()} onClose={() => setIsMoving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{i18n.locale() === 'cs' ? 'Přejít na jinou lekci' : 'Move to another session'}
</DialogTitle>
<DialogDescription>
{i18n.locale() === 'cs'
? 'Vyberte jiný termín téže lekce. Plná lekce vás zařadí na čekací listinu.'
: 'Pick another time for the same class. A full session places you on its waitlist.'}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 mt-4">
<Show when={!moveTargets.loading} fallback={
<div class="p-4 text-center text-sm text-ink-muted animate-pulse">…</div>
}>
<Show when={(moveTargets() ?? []).length > 0} fallback={
<p class="p-4 text-sm text-ink-muted bg-canvas-subtle rounded-xl text-center">
{i18n.locale() === 'cs' ? 'Žádné další nadcházející termíny této lekce.' : 'No other upcoming sessions for this class.'}
</p>
}>
<div class="space-y-2 max-h-80 overflow-y-auto pr-1">
<For each={moveTargets() ?? []}>
{(target: any) => {
const isSelected = () => selectedSession() === target.sessionId;
const full = () => target.remaining <= 0;
return (
<button
type="button"
onClick={() => setSelectedSession(target.sessionId)}
class={`w-full text-left p-3 rounded-xl border transition-colors ${
isSelected()
? 'border-accent bg-accent-subtle'
: 'border-border bg-canvas-subtle hover:border-accent'
}`}
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-ink text-sm">{formatDate(target.startsAt)}</p>
<p class="text-xs text-ink-muted mt-0.5">
{formatTime(target.startsAt)} – {formatTime(target.endsAt)}
{target.location ? ` · ${target.location}` : ''}
</p>
</div>
<span class={`shrink-0 text-xs font-medium px-2.5 py-1 rounded-full ${
full()
? 'bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))]'
: 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
}`}>
{full()
? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlist')
: (i18n.locale() === 'cs' ? `${target.remaining} volných` : `${target.remaining} free`)}
</span>
</div>
</button>
);
}}
</For>
</div>
</Show>
</Show>
<div class="flex gap-3 pt-2">
<Button variant="secondary" class="flex-1" onClick={() => { setIsMoving(false); setSelectedSession(null); }}>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</Button>
<Button class="flex-1" onClick={handleMove} disabled={!selectedSession() || moveBusy()}>
{moveBusy()
? '…'
: (i18n.locale() === 'cs' ? 'Přejít na lekci' : 'Move to session')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Cancel Dialog */}
<Dialog open={isCancelling()} onClose={() => setIsCancelling(false)}>
<DialogContent>