feat(catalog): add service and staff management end-to-end

Services and staff existed as schema + booking FK targets but had no
management surface. Adds full CRUD: repository (pgx + in-memory with
matching FK semantics), catalog service validation with tenant isolation,
MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas,
and dashboard screens for both entities.

Dashboard summary now resolves staff names on bookings and computes
setup completion from real configured state. Booking modal selects from
the tenant's actual services; blocked days can be scoped per staff member.

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 08:52:25 +02:00
1 parent 7a6223c07b
commit 09f780e7a6
21 files changed
+2422 -15

No files matched your search

@@ -1,4 +1,4 @@
import { Show, createSignal, createMemo } from "solid-js";
import { Show, createSignal, createMemo, createResource } from "solid-js";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Textarea } from "../../components/ui/textarea";
@@ -25,6 +25,28 @@ function BookingsPage() {
const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? [];
const [serviceOptions] = createResource(
() => [data.token(), showNewBooking()] as const,
async ([bearer, open]) => {
if (!bearer || !open) return [];
if (bearer.startsWith("demo.")) {
const names = data.i18n.locale() === "cs"
? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"]
: ["Massage", "Cosmetics", "Physiotherapy", "Manicure"];
return names.map((name) => ({ value: name, label: name }));
}
try {
const response = await apiClient.GET("/v1/catalog/services", { headers: { Authorization: `Bearer ${bearer}` } });
return ((response.data as any[]) ?? []).map((s) => ({
value: s.name,
label: `${s.name} · ${s.durationMinutes} min`,
}));
} catch {
return [];
}
},
);
const filteredBookings = createMemo(() => {
let bookings = resolvedAllBookings();
if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus());
@@ -224,7 +246,16 @@ function BookingsPage() {
<div class="p-6 space-y-4">
<Input type="text" label={data.i18n.t("booking.customer.name")} value={newBookingCustomer()} onInput={(e) => setNewBookingCustomer(e.currentTarget.value)} />
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
<Show when={(serviceOptions() ?? []).length > 0} fallback={
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
}>
<Select
label={data.i18n.t("dashboard.bookingModal.service")}
value={newBookingService()}
onChange={(v) => setNewBookingService(v)}
options={[{ value: "", label: data.i18n.t("dashboard.bookingModal.selectService") }, ...(serviceOptions() ?? [])]}
/>
</Show>
<div class="grid grid-cols-2 gap-4">
<Input type="date" label={data.i18n.t("dashboard.bookingModal.date")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
<Input type="time" label={data.i18n.t("dashboard.bookingModal.time")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />