setNewBookingCustomer(e.currentTarget.value)} />
setNewBookingEmail(e.currentTarget.value)} />
-
setNewBookingService(e.currentTarget.value)} />
+
0} fallback={
+ setNewBookingService(e.currentTarget.value)} />
+ }>
+
setNewBookingDate(e.currentTarget.value)} />
setNewBookingTime(e.currentTarget.value)} />
diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts
index fd829bc..3a1eca2 100644
--- a/apps/frontend/src/routes/dashboard/dashboard-data.ts
+++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts
@@ -4,7 +4,7 @@ import { useAuth } from "../../providers/auth-provider";
import { useI18n } from "../../providers/i18n-provider";
import { getInitials, getBookingDuration } from "../../components/dashboard/types";
-export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings";
+export type Section = "overview" | "bookings" | "customers" | "services" | "staff" | "zones" | "billing" | "settings";
function createDemoData(i18n: ReturnType
) {
const cs = i18n.locale() === "cs";
diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx
index dd053ee..0e8d27e 100644
--- a/apps/frontend/src/routes/dashboard/layout.tsx
+++ b/apps/frontend/src/routes/dashboard/layout.tsx
@@ -8,7 +8,7 @@ import { getInitials, getBookingDuration } from "../../components/dashboard/type
import {
LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon,
LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon,
- UserCircleIcon, MapPinIcon
+ UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon
} from "../../components/dashboard/icons";
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
@@ -18,6 +18,8 @@ const navItems = [
{ id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" },
{ id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" },
{ id: "customers", label: "dashboard.customers", icon: UserCircleIcon, path: "/dashboard/customers" },
+ { id: "services", label: "dashboard.services", icon: ScissorsIcon, path: "/dashboard/services" },
+ { id: "staff", label: "dashboard.staff", icon: UsersIcon, path: "/dashboard/staff" },
{ id: "zones", label: "dashboard.zones", icon: MapPinIcon, path: "/dashboard/zones" },
{ id: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" },
{ id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" },
diff --git a/apps/frontend/src/routes/dashboard/services-page.tsx b/apps/frontend/src/routes/dashboard/services-page.tsx
new file mode 100644
index 0000000..273c633
--- /dev/null
+++ b/apps/frontend/src/routes/dashboard/services-page.tsx
@@ -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(null);
+ const [form, setForm] = createSignal({ ...emptyForm });
+ const [saving, setSaving] = createSignal(false);
+
+ const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
+ const setField = (patch: Partial) => 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 (
+
+
+
+
{data.i18n.t("dashboard.services")}
+
{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}
+
+
+
+
+
+
+
+
{editingId() ? data.i18n.t("dashboard.service.edit") : data.i18n.t("dashboard.service.add")}
+
+
+
+
+
+
+
+
+
{cs() ? "Načítání…" : "Loading…"}
+ }>
+
0} fallback={
+
+
+
{data.i18n.t("dashboard.service.noServices")}
+
{data.i18n.t("dashboard.service.noServicesDesc")}
+
+ }>
+
+
+ {(service: any) => (
+
+
+
+
+
{service.name}
+
+ {service.durationMinutes} {data.i18n.t("dashboard.service.min")}
+ 0 || service.bufferAfterMinutes > 0}>
+
+ • +{service.bufferBeforeMinutes ?? 0}/{service.bufferAfterMinutes ?? 0} {data.i18n.t("dashboard.service.min")} {cs() ? "rezerva" : "buffer"}
+
+
+
+
+
+
+ {formatPrice(service.priceCents)}
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default function ServicesRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/frontend/src/routes/dashboard/staff-page.tsx b/apps/frontend/src/routes/dashboard/staff-page.tsx
new file mode 100644
index 0000000..9f2dbc6
--- /dev/null
+++ b/apps/frontend/src/routes/dashboard/staff-page.tsx
@@ -0,0 +1,264 @@
+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, UsersIcon, Trash2Icon, EditIcon, SparklesIcon, MapPinIcon } from "../../components/dashboard/icons";
+import { DashboardLayout, useDashboardData } from "./layout";
+
+interface StaffForm {
+ displayName: string;
+ locationId: string;
+}
+
+const emptyForm: StaffForm = { displayName: "", locationId: "" };
+
+function StaffPage() {
+ const data = useDashboardData();
+ const cs = () => data.i18n.locale() === "cs";
+ const [showForm, setShowForm] = createSignal(false);
+ const [editingId, setEditingId] = createSignal
(null);
+ const [form, setForm] = createSignal({ ...emptyForm });
+ const [saving, setSaving] = createSignal(false);
+ const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
+
+ const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
+ const setField = (patch: Partial) => setForm({ ...form(), ...patch });
+
+ const [staff, { refetch }] = createResource(
+ () => data.token(),
+ async (bearer) => {
+ if (!bearer) return [];
+ if (bearer.startsWith("demo.")) {
+ return [
+ { id: "st1", displayName: "Petra M.", active: true, locationId: "z1" },
+ { id: "st2", displayName: "Lucie K.", active: true, locationId: "z2" },
+ { id: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", active: false, locationId: null },
+ ];
+ }
+ const response = await apiClient.GET("/v1/catalog/staff", authHeaders());
+ return (response.data as any[]) ?? [];
+ },
+ );
+
+ const [locations] = createResource(
+ () => data.token(),
+ async (bearer) => {
+ if (!bearer) return [];
+ if (bearer.startsWith("demo.")) {
+ return [
+ { id: "z1", name: cs() ? "Hlavní sál" : "Main Hall" },
+ { id: "z2", name: cs() ? "Soukromá místnost A" : "Private Room A" },
+ ];
+ }
+ const response = await apiClient.GET("/v1/catalog/locations", authHeaders());
+ return (response.data as any[]) ?? [];
+ },
+ );
+
+ const resolvedStaff = () => staff() ?? [];
+ const resolvedLocations = () => locations() ?? [];
+ const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1;
+ const canAddStaff = () => staffLimit() < 0 || resolvedStaff().length < staffLimit();
+
+ const locationName = (id: string | null | undefined) =>
+ resolvedLocations().find((l: any) => l.id === id)?.name ?? data.i18n.t("dashboard.staff.noLocation");
+
+ const locationOptions = () => [
+ { value: "", label: data.i18n.t("dashboard.staff.noLocation") },
+ ...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
+ ];
+
+ const openCreate = () => {
+ setEditingId(null);
+ setForm({ ...emptyForm });
+ setShowForm(true);
+ };
+
+ const openEdit = (member: any) => {
+ setEditingId(member.id);
+ setForm({ displayName: member.displayName, locationId: member.locationId ?? "" });
+ setShowForm(true);
+ };
+
+ const handleSave = async () => {
+ const bearer = data.token();
+ const name = form().displayName.trim();
+ if (!name) 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 id = editingId();
+ const locationId = form().locationId;
+ const response = id
+ ? await apiClient.PUT("/v1/catalog/staff/{staffID}", {
+ ...authHeaders(),
+ params: { path: { staffID: id } },
+ body: { displayName: name, locationId: locationId || "" },
+ })
+ : await apiClient.POST("/v1/catalog/staff", {
+ ...authHeaders(),
+ body: { displayName: name, ...(locationId ? { locationId } : {}) },
+ });
+ if (response.error) throw new Error("save failed");
+ setShowForm(false);
+ setEditingId(null);
+ setForm({ ...emptyForm });
+ void refetch();
+ } catch {
+ data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggleActive = async (member: any) => {
+ const bearer = data.token();
+ if (!bearer || bearer.startsWith("demo.")) {
+ data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
+ return;
+ }
+ try {
+ await apiClient.PUT("/v1/catalog/staff/{staffID}", {
+ ...authHeaders(),
+ params: { path: { staffID: member.id } },
+ body: { displayName: member.displayName, active: !member.active },
+ });
+ void refetch();
+ } catch {
+ data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed"));
+ }
+ };
+
+ 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/staff/{staffID}", { ...authHeaders(), params: { path: { staffID: id } } });
+ void refetch();
+ } catch {
+ data.setDemoNotice(data.i18n.t("dashboard.staff.deleteFailed"));
+ }
+ };
+
+ return (
+
+
+
+
{data.i18n.t("dashboard.staff")}
+
{resolvedStaff().length} {data.i18n.t("dashboard.staff.subtitle")}
+
+
setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-80">
+
+ {data.i18n.t("dashboard.zone.limitReached")}
+
+ }>
+
+
+
+
+
+
+
+
{editingId() ? data.i18n.t("dashboard.staff.edit") : data.i18n.t("dashboard.staff.add")}
+
+ setField({ displayName: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.staff.namePlaceholder")} />
+
+
+
+
+
+
+
+
+
{cs() ? "Načítání…" : "Loading…"}
+ }>
+
0} fallback={
+
+
+
{data.i18n.t("dashboard.staff.noStaff")}
+
{data.i18n.t("dashboard.staff.noStaffDesc")}
+
+ }>
+
+
+ {(member: any) => (
+
+
+
+ {member.displayName?.split(" ").map((n: string) => n[0]).join("").slice(0, 2).toUpperCase()}
+
+
+
{member.displayName}
+
+ {locationName(member.locationId)}
+
+
+
+
+
+
+ {member.active ? data.i18n.t("dashboard.staff.active") : data.i18n.t("dashboard.staff.inactive")}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
setShowUpgradePrompt(false)} />
+
+
+
+
+
{data.i18n.t("dashboard.staff.limitReached")}
+
{data.i18n.t("dashboard.staff.limitDesc")}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function StaffRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/frontend/src/routes/dashboard/zones-page.tsx b/apps/frontend/src/routes/dashboard/zones-page.tsx
index 476b836..75d0dad 100644
--- a/apps/frontend/src/routes/dashboard/zones-page.tsx
+++ b/apps/frontend/src/routes/dashboard/zones-page.tsx
@@ -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(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() {
{cs() ? "Dny, kdy nejsou dostupné rezervace." : "Days when bookings are unavailable."}