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

@@ -0,0 +1,204 @@
import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
interface ServiceForm {
name: string;
durationMinutes: string;
bufferBeforeMinutes: string;
bufferAfterMinutes: string;
price: string;
}
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0" };
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 },
{ 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 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 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),
});
setShowForm(true);
};
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)),
};
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>
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.service.add")}</button>
</div>
<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="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">
&bull; +{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>
);
}