mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
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">
|
||||
|
||||
Reference in new issue
Block a user