Files
Bookra/apps/frontend/src/routes/dashboard/bookings-page.tsx
T
Tomas DvorakandDevin 09f780e7a6 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>
2026-09-18 08:52:25 +02:00

286 lines
16 KiB
TypeScript

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";
import { getInitials } from "../../components/dashboard/types";
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
import { apiClient } from "../../lib/api-client";
import { DashboardLayout, useDashboardData } from "./layout";
function BookingsPage() {
const data = useDashboardData();
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
const [searchQuery, setSearchQuery] = createSignal("");
const [showNewBooking, setShowNewBooking] = createSignal(false);
const [creatingBooking, setCreatingBooking] = createSignal(false);
const [pinnedBookingIds, setPinnedBookingIds] = createSignal<Set<string>>(new Set());
const [newBookingCustomer, setNewBookingCustomer] = createSignal("");
const [newBookingEmail, setNewBookingEmail] = createSignal("");
const [newBookingService, setNewBookingService] = createSignal("");
const [newBookingDate, setNewBookingDate] = createSignal("");
const [newBookingTime, setNewBookingTime] = createSignal("");
const [newBookingNotes, setNewBookingNotes] = createSignal("");
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());
const now = new Date();
if (filterDateRange() === "today") bookings = bookings.filter((b: any) => new Date(b.startsAt).toDateString() === now.toDateString());
else if (filterDateRange() === "week") {
const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= weekEnd; });
} else if (filterDateRange() === "month") {
const monthEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= monthEnd; });
}
if (searchQuery().trim()) {
const q = searchQuery().toLowerCase();
bookings = bookings.filter((b: any) =>
b.customerName?.toLowerCase().includes(q) ||
b.service?.toLowerCase().includes(q) ||
b.reference?.toLowerCase().includes(q)
);
}
return bookings.sort((a: any, b: any) => {
const aPinned = pinnedBookingIds().has(a.id) ? 1 : 0;
const bPinned = pinnedBookingIds().has(b.id) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
return new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime();
});
});
const bookingStats = createMemo(() => {
const bookings = resolvedAllBookings();
return {
total: bookings.length,
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
pending: bookings.filter((b: any) => b.status === "pending").length,
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
completed: bookings.filter((b: any) => b.status === "completed").length,
};
});
const handleCreateBooking = async () => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze vytvářet rezervace." : "Cannot create bookings in demo mode.");
setShowNewBooking(false); return;
}
setCreatingBooking(true);
try {
const response = await (apiClient as any).POST("/v1/catalog/bookings", {
headers: { Authorization: `Bearer ${bearer}` },
body: {
customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(),
startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes()
}
});
if (response.error) throw new Error(typeof response.error === "string" ? response.error : "booking create failed");
setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes("");
setShowNewBooking(false);
void data.refetchSummary();
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoření rezervace selhalo." : "Failed to create booking."); }
finally { setCreatingBooking(false); }
};
const statusLabels: Record<string, string> = {
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
};
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.bookingManagement")}</h1>
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
</div>
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
</button>
</div>
{/* Stats */}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending },
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
].map((s) => (
<button onClick={() => setFilterStatus(s.key as any)} class={`surface-card p-4 text-left transition-all hover:shadow-md ${filterStatus() === s.key ? "ring-2 ring-accent" : ""}`}>
<p class="text-xs text-ink-muted uppercase tracking-wider">{s.label}</p>
<p class="text-2xl font-bold text-ink mt-1 font-display">{s.value}</p>
</button>
))}
</div>
{/* Filters */}
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<Input type="text" placeholder={data.i18n.t("dashboard.search.bookings")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
</div>
<div class="flex gap-2">
<Select value={filterStatus()} onChange={(v) => setFilterStatus(v as any)} options={[
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
{ value: "pending", label: data.i18n.t("dashboard.pending") },
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
{ value: "completed", label: data.i18n.t("dashboard.completed") },
]} />
<Select value={filterDateRange()} onChange={(v) => setFilterDateRange(v as any)} options={[
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "today", label: data.i18n.t("dashboard.filter.today") },
{ value: "week", label: data.i18n.t("dashboard.filter.week") },
{ value: "month", label: data.i18n.t("dashboard.filter.month") },
]} />
</div>
</div>
{/* Bookings List */}
<div class="surface-card overflow-hidden">
<Show when={filteredBookings().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">
<CalendarDaysIcon />
</div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.empty.bookingsDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
{filteredBookings().map((booking: any) => {
const isPinned = () => pinnedBookingIds().has(booking.id);
const togglePin = (e: Event) => {
e.stopPropagation();
setPinnedBookingIds((prev) => {
const next = new Set(prev);
if (next.has(booking.id)) next.delete(booking.id);
else next.add(booking.id);
return next;
});
};
return (
<div class={`flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors group ${isPinned() ? "bg-accent-soft/40" : ""}`}>
<div class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
booking.status === "confirmed" ? "bg-accent text-canvas" : "bg-canvas-muted text-ink-muted"
}`}>
{getInitials(booking.customerName)}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="font-medium text-ink">{booking.customerName}</p>
{isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />}
</div>
<p class="text-sm text-ink-muted">{booking.service} &bull; {new Date(booking.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "numeric" })} {new Date(booking.startsAt).toLocaleTimeString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={togglePin}
class={`flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200 ${
isPinned()
? "bg-accent text-canvas hover:bg-accent-hover"
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
}`}
aria-label={isPinned() ? "Unpin" : "Pin"}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" stroke-width="1" class={isPinned() ? "opacity-0" : ""} />
</svg>
</button>
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{statusLabels[booking.status]}
</span>
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
<EyeIcon />
</button>
</div>
</div>
);
})}
</div>
</Show>
</div>
{/* New Booking Modal */}
<Show when={showNewBooking()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowNewBooking(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.newBooking")}</h3>
<button onClick={() => setShowNewBooking(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"><XIcon /></button>
</div>
<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)} />
<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)} />
</div>
<Textarea label={data.i18n.t("booking.customer.notes")} value={newBookingNotes()} onInput={(e) => setNewBookingNotes(e.currentTarget.value)} rows={3} resize="none" />
</div>
<div class="p-6 border-t border-border flex gap-3">
<button onClick={() => setShowNewBooking(false)} class="flex-1 px-4 py-2.5 border border-border rounded-xl text-ink hover:bg-canvas-subtle transition-colors">{data.i18n.t("common.cancel")}</button>
<button onClick={handleCreateBooking} disabled={creatingBooking() || !newBookingCustomer() || !newBookingEmail() || !newBookingDate() || !newBookingTime()} class="flex-1 px-4 py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center justify-center gap-2">
<Show when={creatingBooking()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
{creatingBooking() ? data.i18n.t("dashboard.creating") : data.i18n.t("dashboard.createBooking")}
</button>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function BookingsRoute() {
return (
<DashboardLayout>
<BookingsPage />
</DashboardLayout>
);
}