mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 07:54:44 +00:00
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:
1 parent
7a6223c07b
commit
09f780e7a6
21 files changed
+2422
-15
No files matched your search
@@ -16,6 +16,7 @@ function ZonesPage() {
|
||||
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
|
||||
const [newBlockedDate, setNewBlockedDate] = createSignal("");
|
||||
const [newBlockedReason, setNewBlockedReason] = createSignal("");
|
||||
const [newBlockedStaffId, setNewBlockedStaffId] = createSignal("");
|
||||
const [blockedSaving, setBlockedSaving] = createSignal(false);
|
||||
const [hoursSaving, setHoursSaving] = createSignal<number | null>(null);
|
||||
|
||||
@@ -47,6 +48,28 @@ function ZonesPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const [staffMembers] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
if (!bearer || tab !== "blocked") return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
return [
|
||||
{ id: "st1", displayName: "Petra M.", active: true },
|
||||
{ id: "st2", displayName: "Lucie K.", active: true },
|
||||
];
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.GET("/v1/catalog/staff", authHeaders());
|
||||
return ((response.data as any[]) ?? []).filter((m) => m.active);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const staffName = (id: string | null | undefined) =>
|
||||
(staffMembers() ?? []).find((m: any) => m.id === id)?.displayName ?? "";
|
||||
|
||||
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
|
||||
() => [data.token(), activeTab()] as const,
|
||||
async ([bearer, tab]) => {
|
||||
@@ -106,9 +129,14 @@ function ZonesPage() {
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/blocked-days", {
|
||||
...authHeaders(),
|
||||
body: { date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(), type: "closure", reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed") },
|
||||
body: {
|
||||
date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(),
|
||||
type: "closure",
|
||||
reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed"),
|
||||
...(newBlockedStaffId() ? { staffId: newBlockedStaffId() } : {}),
|
||||
},
|
||||
});
|
||||
setNewBlockedDate(""); setNewBlockedReason("");
|
||||
setNewBlockedDate(""); setNewBlockedReason(""); setNewBlockedStaffId("");
|
||||
void refetchBlocked();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||
@@ -236,9 +264,18 @@ function ZonesPage() {
|
||||
<p class="text-sm text-ink-muted mt-1">{cs() ? "Dny, kdy nejsou dostupné rezervace." : "Days when bookings are unavailable."}</p>
|
||||
</div>
|
||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Input type="date" label={cs() ? "Datum" : "Date"} value={newBlockedDate()} onInput={(e) => setNewBlockedDate(e.currentTarget.value)} />
|
||||
<Input type="text" label={cs() ? "Důvod" : "Reason"} value={newBlockedReason()} onInput={(e) => setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} />
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.zone.staff")}
|
||||
value={newBlockedStaffId()}
|
||||
onChange={(v) => setNewBlockedStaffId(v)}
|
||||
options={[
|
||||
{ value: "", label: data.i18n.t("dashboard.zone.allStaff") },
|
||||
...(staffMembers() ?? []).map((m: any) => ({ value: m.id, label: m.displayName })),
|
||||
]}
|
||||
/>
|
||||
<div class="flex items-end">
|
||||
<button onClick={() => void handleAddBlockedDay()} disabled={blockedSaving() || !newBlockedDate()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
|
||||
{blockedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
|
||||
@@ -259,7 +296,12 @@ function ZonesPage() {
|
||||
<div class="flex items-center justify-between p-4 px-6">
|
||||
<div>
|
||||
<p class="font-medium text-ink">{new Date(day.date).toLocaleDateString(cs() ? "cs-CZ" : "en-US", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</p>
|
||||
<p class="text-sm text-ink-muted">{day.reason || "—"}</p>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{day.reason || "—"}
|
||||
<Show when={staffName(day.staffId)}>
|
||||
<span class="text-ink-subtle"> • {staffName(day.staffId)}</span>
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => void handleDeleteBlockedDay(day.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user