feat: scheduled report digests + per-session capacity (M33)

POST /v1/internal/jobs/reports/dispatch emails each tenant owner a
trailing-window digest (bookings, revenue, per-service and per-staff
breakdown) localized to the tenant locale; zero-activity tenants are
skipped. Metrics cores extracted to tenant-scoped variants so the job
needs no principal.

PATCH /v1/catalog/class-sessions/:id sets a session's seat count with
a confirmed-bookings floor — shrinking below it needs allowOverCapacity,
matching the waitlist-promotion convention. Session rows gain an inline
capacity editor with an overbook confirm state.

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 21:29:44 +02:00
1 parent e6f1ec38a4
commit e680e0c2b8
14 files changed
+808 -10

No files matched your search

@@ -457,6 +457,9 @@ const dictionaries = {
"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.editCapacity": "Upravit kapacitu",
"dashboard.class.capacityLabel": "Kapacita",
"dashboard.class.capacityOverbookHint": "Kapacita je nižší než počet potvrzených míst — uložením se lekce přebookuje.",
"dashboard.class.dow.0": "Neděle",
"dashboard.class.dow.1": "Pondělí",
"dashboard.class.dow.2": "Úterý",
@@ -1093,6 +1096,9 @@ const dictionaries = {
"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.editCapacity": "Edit capacity",
"dashboard.class.capacityLabel": "Capacity",
"dashboard.class.capacityOverbookHint": "Capacity is below the confirmed seat count — saving overbooks the session.",
"dashboard.class.dow.0": "Sunday",
"dashboard.class.dow.1": "Monday",
"dashboard.class.dow.2": "Tuesday",
@@ -42,6 +42,12 @@ function ZonesPage() {
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
const [editSchedActive, setEditSchedActive] = createSignal(true);
const [schedEditSaving, setSchedEditSaving] = createSignal(false);
const [editingCapSessionId, setEditingCapSessionId] = createSignal<string | null>(null);
const [editCapValue, setEditCapValue] = createSignal(0);
const [capSaving, setCapSaving] = createSignal(false);
// Session ID whose capacity shrink hit the confirmed-seat floor — the row
// switches to an explicit "set anyway" confirm before resubmitting.
const [capOverbookConfirm, setCapOverbookConfirm] = createSignal<string | null>(null);
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
@@ -448,6 +454,35 @@ function ZonesPage() {
}
};
const startEditCapacity = (session: any) => {
setEditingCapSessionId(session.id);
setEditCapValue(session.capacity);
setCapOverbookConfirm(null);
};
const handleSaveCapacity = async (session: any, allowOverCapacity: boolean) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setCapSaving(true);
try {
const response = await (apiClient as any).PATCH(`/v1/catalog/class-sessions/${session.id}`, {
...authHeaders(),
body: { capacity: editCapValue(), allowOverCapacity },
});
if (response.response?.status === 409 && !allowOverCapacity) {
setCapOverbookConfirm(session.id);
return;
}
setEditingCapSessionId(null);
setCapOverbookConfirm(null);
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setCapSaving(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; }
@@ -891,9 +926,26 @@ function ZonesPage() {
{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={() => startEditCapacity(session)} 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.editCapacity")}><PencilIcon /></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={editingCapSessionId() === session.id}>
<div class="px-6 pb-4 animate-fade-in">
<div class="flex flex-wrap items-end gap-3 rounded-xl border border-border/60 bg-canvas-subtle/30 p-4">
<Input type="number" label={data.i18n.t("dashboard.class.capacityLabel")} value={String(editCapValue())} min={1} onInput={(e) => setEditCapValue(parseInt(e.currentTarget.value, 10) || 0)} />
<button onClick={() => void handleSaveCapacity(session, capOverbookConfirm() === session.id)} disabled={capSaving() || editCapValue() < 1} class="btn-primary text-sm disabled:opacity-60">
<CheckIcon /> {capSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
</button>
<button onClick={() => { setEditingCapSessionId(null); setCapOverbookConfirm(null); }} class="btn-secondary text-sm">
<XIcon /> {data.i18n.t("dashboard.cancel")}
</button>
<Show when={capOverbookConfirm() === session.id}>
<p class="w-full text-sm text-warning font-medium">{data.i18n.t("dashboard.class.capacityOverbookHint")}</p>
</Show>
</div>
</div>
</Show>
<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">