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

@@ -464,6 +464,8 @@ const dictionaries = {
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
"dashboard.services": "Služby",
"dashboard.services.subtitle": "služeb v nabídce",
"dashboard.services.performance": "Výkonnost služeb",
"dashboard.services.classBookings": "rezervací na skupinové lekce (vykazováno zvlášť)",
"dashboard.service.add": "Přidat službu",
"dashboard.service.edit": "Upravit službu",
"dashboard.service.name": "Název služby",
@@ -1093,6 +1095,8 @@ const dictionaries = {
"dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?",
"dashboard.services": "Services",
"dashboard.services.subtitle": "services offered",
"dashboard.services.performance": "Service performance",
"dashboard.services.classBookings": "class session bookings (reported separately)",
"dashboard.service.add": "Add service",
"dashboard.service.edit": "Edit service",
"dashboard.service.name": "Service name",
@@ -398,9 +398,10 @@ export function BookingManageRoute() {
<For each={Object.entries(b().intake)}>
{([key, raw]) => {
const value = String(raw);
const label = b().intakeLabels?.[key] ?? key.replace(/_/g, " ");
return (
<p class="text-sm text-ink">
<span class="text-ink-muted">{key.replace(/_/g, " ")}: </span>
<span class="text-ink-muted">{label}: </span>
{value === "yes" || value === "no" ? i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}
</p>
);
@@ -40,7 +40,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
{ id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(at(-1, 11)), endsAt: fmt(at(-1, 12)), reference: "BOK-004", location: salonA, staffName: staff1, notes: "" },
{ id: "b11", customerName: customers[2].name, customerEmail: customers[2].email, service: services[0], status: "completed", startsAt: fmt(at(-1, 15, 30)), endsAt: fmt(at(-1, 16, 30)), reference: "BOK-011", location: salonA, staffName: staff2, notes: "" },
// today
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "", intake: cs ? { "zdravotni_poznamky": "Citlivá pokožka", "prvni_navsteva": "yes" } : { "health_notes": "Sensitive skin", "first_visit": "yes" } },
{ id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "", intake: cs ? { "zdravotni_poznamky": "Citlivá pokožka", "prvni_navsteva": "yes" } : { "health_notes": "Sensitive skin", "first_visit": "yes" }, intakeLabels: cs ? { "zdravotni_poznamky": "Zdravotní poznámky nebo alergie", "prvni_navsteva": "Je to vaše první návštěva?" } : { "health_notes": "Health notes or allergies", "first_visit": "Is this your first visit?" } },
{ id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(at(0, 16)), endsAt: fmt(at(0, 17)), reference: "BOK-007", location: salonB, staffName: "", notes: "" },
// upcoming
{ id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(at(1, 14)), endsAt: fmt(at(1, 15)), reference: "BOK-002", location: salonA, staffName: staff2, notes: "" },
@@ -336,9 +336,10 @@ function DashboardShell(props: { children: JSX.Element }) {
<For each={Object.entries(data.selectedBooking()!.intake!)}>
{([key, raw]) => {
const value = String(raw);
const label = data.selectedBooking()!.intakeLabels?.[key] ?? key.replace(/_/g, " ");
return (
<div class="flex items-baseline justify-between gap-3">
<span class="text-xs text-ink-muted">{key.replace(/_/g, " ")}</span>
<span class="text-xs text-ink-muted">{label}</span>
<span class="text-sm text-ink">{value === "yes" || value === "no" ? data.i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}</span>
</div>
);
@@ -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">