feat: series-aware schedule edits + waitlist ledger removal (M32)

PATCH /v1/catalog/class-schedules/:id patches a recurring rule and,
with applyToFuture, retimes materialized future sessions to the new
local start time on their existing dates — moving confirmed and
waitlisted bookings with them and re-arming reminders. Zones-page
schedule rows gain an inline editor (CS/EN).

Also drops the vestigial waitlist_entries table (migration 00020):
positions derive from bookings.created_at, so the ledger was written
but never read.

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-22 20:59:34 +02:00
1 parent 55fc0d06e3
commit 65a693cf38
17 files changed
+840 -230

No files matched your search

@@ -452,6 +452,11 @@ const dictionaries = {
"dashboard.class.startsLocal": "Čas",
"dashboard.class.addSchedule": "Přidat pravidlo",
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.",
"dashboard.class.editSchedule": "Upravit pravidlo",
"dashboard.class.applyToFuture": "Použít na budoucí lekce",
"dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.",
"dashboard.class.ruleActive": "Pravidlo je aktivní",
"dashboard.class.inactive": "neaktivní",
"dashboard.class.dow.0": "Neděle",
"dashboard.class.dow.1": "Pondělí",
"dashboard.class.dow.2": "Úterý",
@@ -1083,6 +1088,11 @@ const dictionaries = {
"dashboard.class.startsLocal": "Time",
"dashboard.class.addSchedule": "Add rule",
"dashboard.class.noSchedules": "No recurring rules yet.",
"dashboard.class.editSchedule": "Edit rule",
"dashboard.class.applyToFuture": "Apply to future sessions",
"dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.",
"dashboard.class.ruleActive": "Rule is active",
"dashboard.class.inactive": "inactive",
"dashboard.class.dow.0": "Sunday",
"dashboard.class.dow.1": "Monday",
"dashboard.class.dow.2": "Tuesday",
+106 -14
View File
@@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { getInitials } from "../../components/dashboard/types";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon, CheckIcon, XIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() {
@@ -35,6 +35,13 @@ function ZonesPage() {
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
const [schedSaving, setSchedSaving] = createSignal(false);
const [editingSchedId, setEditingSchedId] = createSignal<string | null>(null);
const [editSchedDayOfWeek, setEditSchedDayOfWeek] = createSignal("1");
const [editSchedStartsLocal, setEditSchedStartsLocal] = createSignal("");
const [editSchedLocationId, setEditSchedLocationId] = createSignal("");
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
const [editSchedActive, setEditSchedActive] = createSignal(true);
const [schedEditSaving, setSchedEditSaving] = 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
@@ -142,8 +149,8 @@ function ZonesPage() {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
];
}
const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders());
@@ -405,6 +412,42 @@ function ZonesPage() {
}
};
const startEditSchedule = (sch: any) => {
setEditingSchedId(sch.id);
setEditSchedDayOfWeek(String(sch.dayOfWeek));
setEditSchedStartsLocal(String(sch.startsLocal).slice(0, 5));
setEditSchedLocationId(sch.locationId ?? "");
setEditSchedApplyFuture(true);
setEditSchedActive(sch.active !== false);
};
const handleSaveSchedule = async () => {
const bearer = data.token();
const id = editingSchedId();
if (!id) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setSchedEditSaving(true);
try {
await (apiClient as any).PATCH(`/v1/catalog/class-schedules/${id}`, {
...authHeaders(),
body: {
dayOfWeek: parseInt(editSchedDayOfWeek(), 10),
startsLocal: editSchedStartsLocal(),
locationId: editSchedLocationId(),
active: editSchedActive(),
applyToFuture: editSchedApplyFuture(),
},
});
setEditingSchedId(null);
void refetchSchedules();
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setSchedEditSaving(false);
}
};
const handleDeleteSchedule = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
@@ -713,19 +756,68 @@ function ZonesPage() {
<div class="divide-y divide-border/60">
<For each={classSchedules() ?? []}>
{(sch: any) => (
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<p class="font-medium text-ink">{sch.templateTitle}</p>
<p class="text-sm text-ink-muted">
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
{sch.locationName ? ` • ${sch.locationName}` : ""}
</p>
<Show
when={editingSchedId() === sch.id}
fallback={
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<p class="font-medium text-ink">{sch.templateTitle}</p>
<p class="text-sm text-ink-muted">
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
{sch.locationName ? ` • ${sch.locationName}` : ""}
{!sch.active ? ` • ${data.i18n.t("dashboard.class.inactive")}` : ""}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<button onClick={() => startEditSchedule(sch)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle/40 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.class.editSchedule")}><PencilIcon /></button>
<button onClick={() => void handleDeleteSchedule(sch.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>
}
>
<div class="p-4 px-6 bg-canvas-subtle/40">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Select
label={data.i18n.t("dashboard.class.weekday")}
value={editSchedDayOfWeek()}
onChange={(v) => setEditSchedDayOfWeek(v)}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekdayName(d) }))}
/>
<Input type="time" label={data.i18n.t("dashboard.class.startsLocal")} value={editSchedStartsLocal()} onInput={(e) => setEditSchedStartsLocal(e.currentTarget.value)} />
<Select
label={data.i18n.t("dashboard.class.location")}
value={editSchedLocationId()}
onChange={(v) => setEditSchedLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
</div>
<label class="flex items-start gap-2 mt-4 cursor-pointer">
<input type="checkbox" checked={editSchedActive()} onChange={(e) => setEditSchedActive(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.ruleActive")}</span>
</label>
<label class="flex items-start gap-2 mt-3 cursor-pointer">
<input type="checkbox" checked={editSchedApplyFuture()} onChange={(e) => setEditSchedApplyFuture(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
<span>
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.applyToFuture")}</span>
<span class="block text-xs text-ink-muted">{data.i18n.t("dashboard.class.applyToFutureHint")}</span>
</span>
</label>
<div class="flex items-center gap-2 mt-4">
<button onClick={() => void handleSaveSchedule()} disabled={schedEditSaving() || !editSchedStartsLocal()} class="btn-primary text-sm disabled:opacity-60">
<CheckIcon /> {schedEditSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
</button>
<button onClick={() => setEditingSchedId(null)} class="btn-secondary text-sm">
<XIcon /> {data.i18n.t("dashboard.cancel")}
</button>
</div>
</div>
<button onClick={() => void handleDeleteSchedule(sch.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>
</Show>
)}
</For>
</div>