mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
- 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>
361 lines
18 KiB
TypeScript
361 lines
18 KiB
TypeScript
import { For, Show, createResource, createSignal } from "solid-js";
|
|
import { apiClient } from "../../lib/api-client";
|
|
import { Input } from "../../components/ui/input";
|
|
import { Select } from "../../components/ui/select";
|
|
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
|
|
import { DashboardLayout, useDashboardData } from "./layout";
|
|
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
|
|
|
interface IntakeDraft {
|
|
key: string;
|
|
label: string;
|
|
type: string;
|
|
required: boolean;
|
|
}
|
|
|
|
const intakeKey = (label: string) =>
|
|
label
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "_")
|
|
.replace(/^_+|_+$/g, "")
|
|
.slice(0, 40);
|
|
|
|
interface ServiceForm {
|
|
name: string;
|
|
durationMinutes: string;
|
|
bufferBeforeMinutes: string;
|
|
bufferAfterMinutes: string;
|
|
price: string;
|
|
intakeFields: IntakeDraft[];
|
|
}
|
|
|
|
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0", intakeFields: [] };
|
|
|
|
function ServicesPage() {
|
|
const data = useDashboardData();
|
|
const cs = () => data.i18n.locale() === "cs";
|
|
const [showForm, setShowForm] = createSignal(false);
|
|
const [editingId, setEditingId] = createSignal<string | null>(null);
|
|
const [form, setForm] = createSignal<ServiceForm>({ ...emptyForm });
|
|
const [saving, setSaving] = createSignal(false);
|
|
|
|
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
|
const setField = (patch: Partial<ServiceForm>) => setForm({ ...form(), ...patch });
|
|
|
|
const [services, { refetch }] = createResource(
|
|
() => data.token(),
|
|
async (bearer) => {
|
|
if (!bearer) return [];
|
|
if (bearer.startsWith("demo.")) {
|
|
return [
|
|
{
|
|
id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000,
|
|
intakeFields: cs()
|
|
? [
|
|
{ key: "zdravotni_poznamky", label: "Zdravotní poznámky nebo alergie", type: "text", required: false },
|
|
{ key: "prvni_navsteva", label: "Je to vaše první návštěva?", type: "yesno", required: true },
|
|
]
|
|
: [
|
|
{ key: "health_notes", label: "Health notes or allergies", type: "text", required: false },
|
|
{ key: "first_visit", label: "Is this your first visit?", type: "yesno", required: true },
|
|
],
|
|
},
|
|
{ id: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", durationMinutes: 45, bufferBeforeMinutes: 0, bufferAfterMinutes: 10, priceCents: 59000 },
|
|
{ id: "sv3", name: cs() ? "Barvení" : "Coloring", durationMinutes: 120, bufferBeforeMinutes: 10, bufferAfterMinutes: 15, priceCents: 199000 },
|
|
{ id: "sv4", name: cs() ? "Konzultace" : "Consultation", durationMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, priceCents: 0 },
|
|
];
|
|
}
|
|
const response = await apiClient.GET("/v1/catalog/services", authHeaders());
|
|
return (response.data as any[]) ?? [];
|
|
},
|
|
);
|
|
|
|
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) => {
|
|
if (!priceCents) return data.i18n.t("dashboard.service.free");
|
|
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 });
|
|
setShowForm(true);
|
|
};
|
|
|
|
const openEdit = (service: any) => {
|
|
setEditingId(service.id);
|
|
setForm({
|
|
name: service.name,
|
|
durationMinutes: String(service.durationMinutes ?? 60),
|
|
bufferBeforeMinutes: String(service.bufferBeforeMinutes ?? 0),
|
|
bufferAfterMinutes: String(service.bufferAfterMinutes ?? 0),
|
|
price: String((service.priceCents ?? 0) / 100),
|
|
intakeFields: (service.intakeFields ?? []).map((f: any) => ({ key: f.key ?? "", label: f.label ?? "", type: f.type ?? "text", required: !!f.required })),
|
|
});
|
|
setShowForm(true);
|
|
};
|
|
|
|
const setIntake = (i: number, patch: Partial<IntakeDraft>) => {
|
|
const fields = form().intakeFields.slice();
|
|
fields[i] = { ...fields[i], ...patch };
|
|
setField({ intakeFields: fields });
|
|
};
|
|
const addIntake = () => {
|
|
if (form().intakeFields.length >= 8) return;
|
|
setField({ intakeFields: [...form().intakeFields, { key: "", label: "", type: "text", required: false }] });
|
|
};
|
|
const removeIntake = (i: number) => {
|
|
setField({ intakeFields: form().intakeFields.filter((_, j) => j !== i) });
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const bearer = data.token();
|
|
const name = form().name.trim();
|
|
const duration = parseInt(form().durationMinutes, 10);
|
|
if (!name || !duration || duration <= 0) return;
|
|
if (!bearer || bearer.startsWith("demo.")) {
|
|
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const body = {
|
|
name,
|
|
durationMinutes: duration,
|
|
bufferBeforeMinutes: Math.max(0, parseInt(form().bufferBeforeMinutes, 10) || 0),
|
|
bufferAfterMinutes: Math.max(0, parseInt(form().bufferAfterMinutes, 10) || 0),
|
|
priceCents: Math.max(0, Math.round((parseFloat(form().price) || 0) * 100)),
|
|
intakeFields: form().intakeFields
|
|
.filter((f) => f.label.trim())
|
|
.map((f) => ({
|
|
key: f.key || intakeKey(f.label),
|
|
label: f.label.trim(),
|
|
type: (f.type === "yesno" ? "yesno" : "text") as "text" | "yesno",
|
|
required: f.required,
|
|
})),
|
|
};
|
|
const id = editingId();
|
|
const response = id
|
|
? await apiClient.PUT("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } }, body })
|
|
: await apiClient.POST("/v1/catalog/services", { ...authHeaders(), body });
|
|
if (response.error) throw new Error("save failed");
|
|
setShowForm(false);
|
|
setEditingId(null);
|
|
setForm({ ...emptyForm });
|
|
void refetch();
|
|
} catch {
|
|
data.setDemoNotice(data.i18n.t("dashboard.service.saveFailed"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = 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;
|
|
}
|
|
try {
|
|
await apiClient.DELETE("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } } });
|
|
void refetch();
|
|
} catch {
|
|
data.setDemoNotice(data.i18n.t("dashboard.service.deleteFailed"));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div class="space-y-6">
|
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.services")}</h1>
|
|
<p class="text-ink-muted mt-1">{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<ExportCsvButton path="/v1/export/service-report.csv?days=30" filename="bookra-service-report.csv" token={data.token} />
|
|
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.service.add")}</button>
|
|
</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">
|
|
<h4 class="font-medium text-ink mb-4">{editingId() ? data.i18n.t("dashboard.service.edit") : data.i18n.t("dashboard.service.add")}</h4>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4">
|
|
<div class="sm:col-span-2">
|
|
<Input type="text" label={data.i18n.t("dashboard.service.name")} value={form().name} onInput={(e) => setField({ name: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.namePlaceholder")} />
|
|
</div>
|
|
<Input type="number" min="1" label={data.i18n.t("dashboard.service.duration")} value={form().durationMinutes} onInput={(e) => setField({ durationMinutes: e.currentTarget.value })} />
|
|
<Input type="number" min="0" label={data.i18n.t("dashboard.service.price")} value={form().price} onInput={(e) => setField({ price: e.currentTarget.value })} />
|
|
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferBefore")} value={form().bufferBeforeMinutes} onInput={(e) => setField({ bufferBeforeMinutes: e.currentTarget.value })} />
|
|
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferAfter")} value={form().bufferAfterMinutes} onInput={(e) => setField({ bufferAfterMinutes: e.currentTarget.value })} />
|
|
</div>
|
|
<div class="mt-4 pt-4 border-t border-border/60">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<p class="text-sm font-medium text-ink">{data.i18n.t("dashboard.service.intake")}</p>
|
|
<Show when={form().intakeFields.length < 8}>
|
|
<button onClick={addIntake} class="text-xs text-accent hover:underline flex items-center gap-1"><PlusIcon /> {data.i18n.t("dashboard.service.intakeAdd")}</button>
|
|
</Show>
|
|
</div>
|
|
<Show when={form().intakeFields.length > 0} fallback={
|
|
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.service.intakeHint")}</p>
|
|
}>
|
|
<div class="space-y-2">
|
|
<For each={form().intakeFields}>
|
|
{(field, i) => (
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex-1">
|
|
<Input type="text" value={field.label} onInput={(e) => setIntake(i(), { label: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.intakeLabel")} />
|
|
</div>
|
|
<Select
|
|
value={field.type}
|
|
onChange={(v) => setIntake(i(), { type: v })}
|
|
options={[
|
|
{ value: "text", label: data.i18n.t("dashboard.service.intakeText") },
|
|
{ value: "yesno", label: data.i18n.t("dashboard.service.intakeYesNo") },
|
|
]}
|
|
/>
|
|
<label class="flex items-center gap-1.5 text-xs text-ink-muted whitespace-nowrap cursor-pointer">
|
|
<input type="checkbox" checked={field.required} onChange={(e) => setIntake(i(), { required: e.currentTarget.checked })} class="rounded accent-accent" />
|
|
{data.i18n.t("dashboard.service.intakeRequired")}
|
|
</label>
|
|
<button onClick={() => removeIntake(i())} class="p-1.5 text-ink-subtle hover:text-red-600 rounded-lg" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
<div class="flex gap-3 mt-4">
|
|
<button onClick={() => void handleSave()} disabled={saving() || !form().name.trim()} class="btn-primary text-sm disabled:opacity-60">
|
|
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
|
</button>
|
|
<button onClick={() => { setShowForm(false); setEditingId(null); }} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!services.loading} fallback={
|
|
<div class="p-12 text-center text-ink-muted text-sm animate-pulse">{cs() ? "Načítání…" : "Loading…"}</div>
|
|
}>
|
|
<Show when={resolvedServices().length > 0} fallback={
|
|
<div class="p-12 text-center">
|
|
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4 text-ink-subtle"><ScissorsIcon /></div>
|
|
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.service.noServices")}</p>
|
|
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.service.noServicesDesc")}</p>
|
|
</div>
|
|
}>
|
|
<div class="divide-y divide-border/60">
|
|
<For each={resolvedServices()}>
|
|
{(service: any) => (
|
|
<div class="flex items-center justify-between p-5 px-6 hover:bg-canvas-subtle/30 transition-colors">
|
|
<div class="flex items-center gap-4 min-w-0">
|
|
<div class="w-12 h-12 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent shrink-0"><ScissorsIcon /></div>
|
|
<div class="min-w-0">
|
|
<h4 class="font-medium text-ink truncate">{service.name}</h4>
|
|
<p class="text-sm text-ink-muted flex items-center gap-1.5">
|
|
<ClockIcon /> {service.durationMinutes} {data.i18n.t("dashboard.service.min")}
|
|
<Show when={service.bufferBeforeMinutes > 0 || service.bufferAfterMinutes > 0}>
|
|
<span class="text-ink-subtle">
|
|
• +{service.bufferBeforeMinutes ?? 0}/{service.bufferAfterMinutes ?? 0} {data.i18n.t("dashboard.service.min")} {cs() ? "rezerva" : "buffer"}
|
|
</span>
|
|
</Show>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<span class="hidden sm:inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-accent/10 text-accent">{formatPrice(service.priceCents)}</span>
|
|
<button onClick={() => openEdit(service)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.edit")}>
|
|
<EditIcon />
|
|
</button>
|
|
<button onClick={() => void handleDelete(service.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>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ServicesRoute() {
|
|
return (
|
|
<DashboardLayout>
|
|
<ServicesPage />
|
|
</DashboardLayout>
|
|
);
|
|
}
|