mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +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
@@ -114,6 +114,12 @@ export const PlusIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ScissorsIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const UsersIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
|
||||
@@ -16,6 +16,8 @@ const DashboardRoute = lazy(() => import("./routes/dashboard/overview-page"));
|
||||
const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page"));
|
||||
const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-page"));
|
||||
const DashboardZonesRoute = lazy(() => import("./routes/dashboard/zones-page"));
|
||||
const DashboardServicesRoute = lazy(() => import("./routes/dashboard/services-page"));
|
||||
const DashboardStaffRoute = lazy(() => import("./routes/dashboard/staff-page"));
|
||||
const DashboardBillingRoute = lazy(() => import("./routes/dashboard/billing-page"));
|
||||
const DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page"));
|
||||
const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute })));
|
||||
@@ -35,6 +37,8 @@ render(
|
||||
<Route path="/dashboard/bookings" component={DashboardBookingsRoute} />
|
||||
<Route path="/dashboard/customers" component={DashboardCustomersRoute} />
|
||||
<Route path="/dashboard/zones" component={DashboardZonesRoute} />
|
||||
<Route path="/dashboard/services" component={DashboardServicesRoute} />
|
||||
<Route path="/dashboard/staff" component={DashboardStaffRoute} />
|
||||
<Route path="/dashboard/billing" component={DashboardBillingRoute} />
|
||||
<Route path="/dashboard/settings" component={DashboardSettingsRoute} />
|
||||
<Route path="/book/:tenantSlug" component={PublicBookingRoute} />
|
||||
|
||||
@@ -398,6 +398,41 @@ const dictionaries = {
|
||||
"dashboard.zone.addBlocked": "Přidat blokovaný den",
|
||||
"dashboard.zone.reason": "Důvod",
|
||||
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
|
||||
"dashboard.zone.staff": "Zaměstnanec",
|
||||
"dashboard.zone.allStaff": "Všichni zaměstnanci",
|
||||
"dashboard.services": "Služby",
|
||||
"dashboard.services.subtitle": "služeb v nabídce",
|
||||
"dashboard.service.add": "Přidat službu",
|
||||
"dashboard.service.edit": "Upravit službu",
|
||||
"dashboard.service.name": "Název služby",
|
||||
"dashboard.service.namePlaceholder": "např. Dámský střih",
|
||||
"dashboard.service.duration": "Délka (min)",
|
||||
"dashboard.service.bufferBefore": "Přestávka před",
|
||||
"dashboard.service.bufferAfter": "Přestávka po",
|
||||
"dashboard.service.price": "Cena (Kč)",
|
||||
"dashboard.service.free": "Zdarma",
|
||||
"dashboard.service.min": "min",
|
||||
"dashboard.service.noServices": "Zatím žádné služby.",
|
||||
"dashboard.service.noServicesDesc": "Přidejte první službu, kterou si klienti mohou rezervovat.",
|
||||
"dashboard.service.saveFailed": "Uložení služby selhalo.",
|
||||
"dashboard.service.deleteFailed": "Smazání služby selhalo.",
|
||||
"dashboard.staff": "Zaměstnanci",
|
||||
"dashboard.staff.subtitle": "členů týmu",
|
||||
"dashboard.staff.add": "Přidat zaměstnance",
|
||||
"dashboard.staff.edit": "Upravit zaměstnance",
|
||||
"dashboard.staff.name": "Jméno",
|
||||
"dashboard.staff.namePlaceholder": "např. Anna Nováková",
|
||||
"dashboard.staff.location": "Lokace",
|
||||
"dashboard.staff.noLocation": "Bez lokace",
|
||||
"dashboard.staff.active": "Aktivní",
|
||||
"dashboard.staff.inactive": "Neaktivní",
|
||||
"dashboard.staff.noStaff": "Zatím žádní zaměstnanci.",
|
||||
"dashboard.staff.noStaffDesc": "Přidejte členy týmu, kteří poskytují služby.",
|
||||
"dashboard.staff.limitReached": "Dosažen limit zaměstnanců",
|
||||
"dashboard.staff.limitDesc": "Váš plán omezuje počet zaměstnanců. Pro více míst upgradujte.",
|
||||
"dashboard.staff.saveFailed": "Uložení zaměstnance selhalo.",
|
||||
"dashboard.staff.deleteFailed": "Smazání zaměstnance selhalo.",
|
||||
"dashboard.bookingModal.selectService": "Vyberte službu",
|
||||
"dashboard.billing.planUsage": "Využití plánu",
|
||||
"dashboard.billing.locations": "Lokace",
|
||||
"dashboard.billing.bookings": "Rezervace",
|
||||
@@ -907,6 +942,41 @@ const dictionaries = {
|
||||
"dashboard.zone.addBlocked": "Add blocked day",
|
||||
"dashboard.zone.reason": "Reason",
|
||||
"dashboard.zone.noZones": "No zones yet. Add your first one.",
|
||||
"dashboard.zone.staff": "Staff member",
|
||||
"dashboard.zone.allStaff": "All staff",
|
||||
"dashboard.services": "Services",
|
||||
"dashboard.services.subtitle": "services offered",
|
||||
"dashboard.service.add": "Add service",
|
||||
"dashboard.service.edit": "Edit service",
|
||||
"dashboard.service.name": "Service name",
|
||||
"dashboard.service.namePlaceholder": "e.g. Women's haircut",
|
||||
"dashboard.service.duration": "Duration (min)",
|
||||
"dashboard.service.bufferBefore": "Buffer before",
|
||||
"dashboard.service.bufferAfter": "Buffer after",
|
||||
"dashboard.service.price": "Price (CZK)",
|
||||
"dashboard.service.free": "Free",
|
||||
"dashboard.service.min": "min",
|
||||
"dashboard.service.noServices": "No services yet.",
|
||||
"dashboard.service.noServicesDesc": "Add the first service clients can book.",
|
||||
"dashboard.service.saveFailed": "Failed to save service.",
|
||||
"dashboard.service.deleteFailed": "Failed to delete service.",
|
||||
"dashboard.staff": "Staff",
|
||||
"dashboard.staff.subtitle": "team members",
|
||||
"dashboard.staff.add": "Add staff member",
|
||||
"dashboard.staff.edit": "Edit staff member",
|
||||
"dashboard.staff.name": "Name",
|
||||
"dashboard.staff.namePlaceholder": "e.g. Anna Novak",
|
||||
"dashboard.staff.location": "Location",
|
||||
"dashboard.staff.noLocation": "No location",
|
||||
"dashboard.staff.active": "Active",
|
||||
"dashboard.staff.inactive": "Inactive",
|
||||
"dashboard.staff.noStaff": "No staff yet.",
|
||||
"dashboard.staff.noStaffDesc": "Add team members who provide services.",
|
||||
"dashboard.staff.limitReached": "Staff limit reached",
|
||||
"dashboard.staff.limitDesc": "Your plan limits the number of staff. Upgrade for more seats.",
|
||||
"dashboard.staff.saveFailed": "Failed to save staff member.",
|
||||
"dashboard.staff.deleteFailed": "Failed to delete staff member.",
|
||||
"dashboard.bookingModal.selectService": "Select service",
|
||||
"dashboard.billing.planUsage": "Plan usage",
|
||||
"dashboard.billing.locations": "Locations",
|
||||
"dashboard.billing.bookings": "Bookings",
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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<typeof useI18n>) {
|
||||
const cs = i18n.locale() === "cs";
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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">
|
||||
• +{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>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [form, setForm] = createSignal<StaffForm>({ ...emptyForm });
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
|
||||
|
||||
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
||||
const setField = (patch: Partial<StaffForm>) => 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 (
|
||||
<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.staff")}</h1>
|
||||
<p class="text-ink-muted mt-1">{resolvedStaff().length} {data.i18n.t("dashboard.staff.subtitle")}</p>
|
||||
</div>
|
||||
<Show when={canAddStaff()} fallback={
|
||||
<button onClick={() => 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">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
{data.i18n.t("dashboard.zone.limitReached")}
|
||||
</button>
|
||||
}>
|
||||
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.staff.add")}</button>
|
||||
</Show>
|
||||
</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.staff.edit") : data.i18n.t("dashboard.staff.add")}</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input type="text" label={data.i18n.t("dashboard.staff.name")} value={form().displayName} onInput={(e) => setField({ displayName: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.staff.namePlaceholder")} />
|
||||
<Select label={data.i18n.t("dashboard.staff.location")} value={form().locationId} onChange={(v) => setField({ locationId: v })} options={locationOptions()} />
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button onClick={() => void handleSave()} disabled={saving() || !form().displayName.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={!staff.loading} fallback={
|
||||
<div class="p-12 text-center text-ink-muted text-sm animate-pulse">{cs() ? "Načítání…" : "Loading…"}</div>
|
||||
}>
|
||||
<Show when={resolvedStaff().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"><UsersIcon /></div>
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.staff.noStaff")}</p>
|
||||
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.staff.noStaffDesc")}</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={resolvedStaff()}>
|
||||
{(member: 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-full flex items-center justify-center text-sm font-bold shrink-0 ${member.active ? "bg-accent-subtle text-accent" : "bg-canvas-muted text-ink-subtle"}`}>
|
||||
{member.displayName?.split(" ").map((n: string) => n[0]).join("").slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="font-medium text-ink truncate">{member.displayName}</h4>
|
||||
<p class="text-sm text-ink-muted flex items-center gap-1.5">
|
||||
<MapPinIcon /> {locationName(member.locationId)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => void handleToggleActive(member)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${member.active ? "bg-accent" : "bg-canvas-muted"}`}
|
||||
role="switch"
|
||||
aria-checked={member.active}
|
||||
aria-label={member.active ? data.i18n.t("dashboard.staff.active") : data.i18n.t("dashboard.staff.inactive")}
|
||||
>
|
||||
<span class={`inline-block h-4 w-4 transform rounded-full bg-canvas transition-transform ${member.active ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
<span class={`hidden sm:inline text-xs font-medium ${member.active ? "text-accent" : "text-ink-subtle"}`}>
|
||||
{member.active ? data.i18n.t("dashboard.staff.active") : data.i18n.t("dashboard.staff.inactive")}
|
||||
</span>
|
||||
<button onClick={() => openEdit(member)} 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(member.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>
|
||||
|
||||
<Show when={showUpgradePrompt()}>
|
||||
<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={() => setShowUpgradePrompt(false)} />
|
||||
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md p-6 animate-scale-in">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0 text-accent"><SparklesIcon /></div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.staff.limitReached")}</h4>
|
||||
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.staff.limitDesc")}</p>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button>
|
||||
<button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StaffRoute() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<StaffPage />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
Reference in new issue
Block a user