feat(metrics): per-staff performance view in dashboard

GET /v1/metrics/staff returns bookings, completed, cancelled,
utilization, and revenue per staff member over a trailing window
(default 30d, max 365d). Appointment bookings only — class sessions
have no staff assignment. All active staff appear, including
zero-booking rows.

Staff page gains a localized Performance panel; demo mode seeds
trailing-window bookings and matching metric rows.

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 16:34:45 +02:00
1 parent 4674203502
commit 76e45d4178
11 files changed
+496 -3

No files matched your search

@@ -55,6 +55,31 @@ function StaffPage() {
},
);
const [metrics] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) {
return {
staff: [
{ staffId: "st1", displayName: "Petra M.", bookings: 12, completed: 9, cancelled: 1, utilizationPercent: 92, revenueCents: 1440000 },
{ staffId: "st2", displayName: "Lucie K.", bookings: 8, completed: 5, cancelled: 2, utilizationPercent: 63, revenueCents: 960000 },
{ staffId: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", bookings: 0, completed: 0, cancelled: 0, utilizationPercent: 0, revenueCents: 0 },
],
};
}
const response = await apiClient.GET("/v1/metrics/staff", authHeaders());
return response.data ?? null;
},
);
const formatRevenue = (cents: number) =>
new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", {
style: "currency",
currency: "CZK",
maximumFractionDigits: 0,
}).format(cents / 100);
const resolvedStaff = () => staff() ?? [];
const resolvedLocations = () => locations() ?? [];
const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1;
@@ -163,6 +188,46 @@ function StaffPage() {
</Show>
</div>
<Show when={(metrics()?.staff?.length ?? 0) > 0}>
<div class="surface-card p-6">
<div class="flex items-baseline justify-between gap-4 mb-5">
<h2 class="text-lg font-semibold text-ink font-display">{data.i18n.t("dashboard.staff.performance")}</h2>
<span class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.performanceWindow")}</span>
</div>
<div class="divide-y divide-border/60">
<For each={metrics()!.staff}>
{(m: any) => (
<div class="py-4 first:pt-0 last:pb-0">
<p class="font-medium text-ink mb-3">{m.displayName}</p>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-x-6 gap-y-3">
<div>
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.bookings")}</p>
<p class="text-lg font-semibold text-ink tabular-nums">{m.bookings}</p>
</div>
<div>
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.completed")}</p>
<p class="text-lg font-semibold text-ink tabular-nums">{m.completed}</p>
</div>
<div>
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.cancelled")}</p>
<p class="text-lg font-semibold text-ink tabular-nums">{m.cancelled}</p>
</div>
<div>
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.kpi.utilization")}</p>
<p class="text-lg font-semibold text-accent tabular-nums">{m.utilizationPercent}%</p>
</div>
<div>
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.revenue")}</p>
<p class="text-lg font-semibold text-ink tabular-nums">{formatRevenue(m.revenueCents)}</p>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="surface-card overflow-hidden">
<Show when={showForm()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">