mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(classes): weekly recurring schedules with lazy session materialization
class_schedules rules generate concrete session rows for a rolling 28-day horizon — no cron; materialize runs idempotently on dashboard and public reads via a partial unique index on (schedule_id, starts_at). Rule delete stops future sessions; existing sessions keep bookings and overrides. 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
7b9080eb1c
commit
ad60167a31
15 files changed
+1029
-7
No files matched your search
@@ -446,6 +446,19 @@ const dictionaries = {
|
||||
"dashboard.class.queuePosition": "v pořadí",
|
||||
"dashboard.class.showRoster": "Účastníci",
|
||||
"dashboard.class.hideRoster": "Skrýt",
|
||||
"dashboard.class.recurring": "Opakující se lekce",
|
||||
"dashboard.class.recurringHint": "Týdenní pravidla automaticky vytvářejí lekce na příštích 28 dní. Smazání pravidla nechává již vytvořené lekce nedotčené.",
|
||||
"dashboard.class.weekday": "Den v týdnu",
|
||||
"dashboard.class.startsLocal": "Čas",
|
||||
"dashboard.class.addSchedule": "Přidat pravidlo",
|
||||
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.",
|
||||
"dashboard.class.dow.0": "Neděle",
|
||||
"dashboard.class.dow.1": "Pondělí",
|
||||
"dashboard.class.dow.2": "Úterý",
|
||||
"dashboard.class.dow.3": "Středa",
|
||||
"dashboard.class.dow.4": "Čtvrtek",
|
||||
"dashboard.class.dow.5": "Pátek",
|
||||
"dashboard.class.dow.6": "Sobota",
|
||||
"dashboard.waitlistPosition": "Pořadí v čekací listině",
|
||||
"dashboard.booking.promote": "Potvrdit z čekací listiny",
|
||||
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
|
||||
@@ -1052,6 +1065,19 @@ const dictionaries = {
|
||||
"dashboard.class.queuePosition": "in queue",
|
||||
"dashboard.class.showRoster": "Roster",
|
||||
"dashboard.class.hideRoster": "Hide",
|
||||
"dashboard.class.recurring": "Recurring classes",
|
||||
"dashboard.class.recurringHint": "Weekly rules automatically create sessions for the next 28 days. Deleting a rule leaves existing sessions untouched.",
|
||||
"dashboard.class.weekday": "Weekday",
|
||||
"dashboard.class.startsLocal": "Time",
|
||||
"dashboard.class.addSchedule": "Add rule",
|
||||
"dashboard.class.noSchedules": "No recurring rules yet.",
|
||||
"dashboard.class.dow.0": "Sunday",
|
||||
"dashboard.class.dow.1": "Monday",
|
||||
"dashboard.class.dow.2": "Tuesday",
|
||||
"dashboard.class.dow.3": "Wednesday",
|
||||
"dashboard.class.dow.4": "Thursday",
|
||||
"dashboard.class.dow.5": "Friday",
|
||||
"dashboard.class.dow.6": "Saturday",
|
||||
"dashboard.waitlistPosition": "Waitlist position",
|
||||
"dashboard.booking.promote": "Promote from waitlist",
|
||||
"dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?",
|
||||
|
||||
@@ -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 } from "../../components/dashboard/icons";
|
||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function ZonesPage() {
|
||||
@@ -30,6 +30,11 @@ function ZonesPage() {
|
||||
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
||||
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
||||
const [sessionSaving, setSessionSaving] = createSignal(false);
|
||||
const [newSchedTemplateId, setNewSchedTemplateId] = createSignal("");
|
||||
const [newSchedDayOfWeek, setNewSchedDayOfWeek] = createSignal("3");
|
||||
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
|
||||
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
|
||||
const [schedSaving, setSchedSaving] = 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
|
||||
@@ -131,6 +136,24 @@ function ZonesPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const [classSchedules, { refetch: refetchSchedules }] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
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 },
|
||||
];
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders());
|
||||
return (response.data as any[]) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
const weekdayName = (dow: number) =>
|
||||
data.i18n.t(`dashboard.class.dow.${dow}`) as string;
|
||||
|
||||
const [roster, { refetch: refetchRoster }] = createResource(
|
||||
() => [data.token(), expandedSessionId()] as const,
|
||||
async ([bearer, sessionId]) => {
|
||||
@@ -357,6 +380,42 @@ function ZonesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSchedule = async () => {
|
||||
const bearer = data.token();
|
||||
if (!newSchedTemplateId() || !newSchedStartsLocal()) return;
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setSchedSaving(true);
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/class-schedules", {
|
||||
...authHeaders(),
|
||||
body: {
|
||||
templateId: newSchedTemplateId(),
|
||||
dayOfWeek: parseInt(newSchedDayOfWeek(), 10),
|
||||
startsLocal: newSchedStartsLocal(),
|
||||
...(newSchedLocationId() ? { locationId: newSchedLocationId() } : {}),
|
||||
},
|
||||
});
|
||||
setNewSchedTemplateId(""); setNewSchedStartsLocal(""); setNewSchedLocationId("");
|
||||
void refetchSchedules();
|
||||
void refetchSessions();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||
} finally {
|
||||
setSchedSaving(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; }
|
||||
try {
|
||||
await (apiClient as any).DELETE(`/v1/catalog/class-schedules/${id}`, authHeaders());
|
||||
void refetchSchedules();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
|
||||
return map[type] || type;
|
||||
@@ -607,6 +666,72 @@ function ZonesPage() {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="surface-card overflow-hidden">
|
||||
<div class="p-6 border-b border-border">
|
||||
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.recurring")}</h3>
|
||||
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.class.recurringHint")}</p>
|
||||
</div>
|
||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.class.template")}
|
||||
value={newSchedTemplateId()}
|
||||
onChange={(v) => setNewSchedTemplateId(v)}
|
||||
options={[
|
||||
{ value: "", label: "—" },
|
||||
...(classTemplates() ?? []).map((t: any) => ({ value: t.id, label: t.title })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.class.weekday")}
|
||||
value={newSchedDayOfWeek()}
|
||||
onChange={(v) => setNewSchedDayOfWeek(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={newSchedStartsLocal()} onInput={(e) => setNewSchedStartsLocal(e.currentTarget.value)} />
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.class.location")}
|
||||
value={newSchedLocationId()}
|
||||
onChange={(v) => setNewSchedLocationId(v)}
|
||||
options={[
|
||||
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
|
||||
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
|
||||
]}
|
||||
/>
|
||||
<div class="flex items-end">
|
||||
<button onClick={() => void handleAddSchedule()} disabled={schedSaving() || !newSchedTemplateId() || !newSchedStartsLocal()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||
{schedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addSchedule")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(classSchedules() ?? []).length > 0} fallback={
|
||||
<div class="p-10 text-center">
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noSchedules")}</p>
|
||||
</div>
|
||||
}>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="surface-card overflow-hidden">
|
||||
<div class="p-6 border-b border-border">
|
||||
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.sessions")}</h3>
|
||||
|
||||
Reference in new issue
Block a user