mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
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>
|
||||
|
||||
Reference in new issue
Block a user