feat: data surfacing — intake labels, service metrics, honest capacity

- booking views gain intakeLabels (declared key→label join); dashboard
  modal + manage view render real question labels with slug fallback
- GET /v1/metrics/services aggregates bookings/completed/cancelled/
  revenue per service + classBookings; service-report.csv delegates to
  the same ServiceMetrics aggregation and gains a completed column
- services page gains a Service performance card (per-service stat grid,
  locale CZK revenue, class-bookings footer); demo rows match the
  fabricated service list
- staff capacity utilization subtracts blocked days — tenant-wide and
  staff-scoped blocks remove that day's rule minutes via blockedAt
- tests: blocked-day capacity (tenant + staff scope), service aggregation

Authored By: TDvorak <[email protected]>

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:25:32 +02:00
1 parent 34becc1bf4
commit 5c860a1264
16 files changed
+560 -69

No files matched your search

@@ -73,6 +73,26 @@ function ServicesPage() {
},
);
const [serviceMetrics] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return null;
if (bearer.startsWith("demo.")) {
return {
services: [
{ serviceId: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", bookings: 9, completed: 5, cancelled: 1, revenueCents: 712000 },
{ serviceId: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", bookings: 6, completed: 4, cancelled: 0, revenueCents: 354000 },
{ serviceId: "sv3", name: cs() ? "Barvení" : "Coloring", bookings: 3, completed: 2, cancelled: 0, revenueCents: 597000 },
{ serviceId: "sv4", name: cs() ? "Konzultace" : "Consultation", bookings: 2, completed: 1, cancelled: 0, revenueCents: 0 },
],
classBookings: 4,
};
}
const response = await apiClient.GET("/v1/metrics/services", authHeaders());
return response.data ?? null;
},
);
const resolvedServices = () => services() ?? [];
const formatPrice = (priceCents: number) => {
@@ -80,6 +100,9 @@ function ServicesPage() {
return new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { style: "currency", currency: "CZK", maximumFractionDigits: 0 }).format(priceCents / 100);
};
const formatRevenue = (cents: number) =>
new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { style: "currency", currency: "CZK", maximumFractionDigits: 0 }).format(cents / 100);
const openCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
@@ -181,6 +204,47 @@ function ServicesPage() {
</div>
</div>
<Show when={(serviceMetrics()?.services?.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.services.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={serviceMetrics()!.services}>
{(m: any) => (
<div class="py-4 first:pt-0 last:pb-0">
<p class="font-medium text-ink mb-3">{m.name}</p>
<div class="grid grid-cols-2 sm:grid-cols-4 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.staff.revenue")}</p>
<p class="text-lg font-semibold text-ink tabular-nums">{formatRevenue(m.revenueCents)}</p>
</div>
</div>
</div>
)}
</For>
</div>
<Show when={(serviceMetrics()?.classBookings ?? 0) > 0}>
<p class="text-xs text-ink-subtle mt-4 pt-4 border-t border-border/60">
{serviceMetrics()!.classBookings} {data.i18n.t("dashboard.services.classBookings")}
</p>
</Show>
</div>
</Show>
<div class="surface-card overflow-hidden">
<Show when={showForm()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">