Files
Bookra/apps/frontend/src/routes/dashboard/zones-page.tsx
T
Tomas DvorakandDevin f1b0a3a2f2 chore: extract shared dashboard chrome strings to i18n keys
Replaces 47 inline cs()/en ternaries with dashboard.* keys: demo
notices, save/delete failures, delete aria-labels, loading, and
zone/blocked-day copy. Demo fixture names, locale tags, and the
zón/locations plural stay inline (Czech plurals need count-aware
forms).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 21:42:25 +02:00

1069 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { getInitials } from "../../components/dashboard/types";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon, CheckIcon, XIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() {
const data = useDashboardData();
const cs = () => data.i18n.locale() === "cs";
const [activeTab, setActiveTab] = createSignal<"zones" | "blocked" | "hours" | "classes">("zones");
const [showAddZone, setShowAddZone] = createSignal(false);
const [newZoneName, setNewZoneName] = createSignal("");
const [newZoneType, setNewZoneType] = createSignal("room");
const [newZoneCapacity, setNewZoneCapacity] = createSignal(10);
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);
const [hoursScope, setHoursScope] = createSignal("");
const [newTemplateTitle, setNewTemplateTitle] = createSignal("");
const [newTemplateDuration, setNewTemplateDuration] = createSignal(60);
const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8);
const [templateSaving, setTemplateSaving] = createSignal(false);
const [editingTemplateId, setEditingTemplateId] = createSignal<string | null>(null);
const [newSessionTemplateId, setNewSessionTemplateId] = createSignal("");
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
const [sessionSaving, setSessionSaving] = createSignal(false);
const [newSchedTemplateId, setNewSchedTemplateId] = createSignal("");
const [newSchedDayOfWeek, setNewSchedDayOfWeek] = createSignal("3");
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
const [schedSaving, setSchedSaving] = createSignal(false);
const [editingSchedId, setEditingSchedId] = createSignal<string | null>(null);
const [editSchedDayOfWeek, setEditSchedDayOfWeek] = createSignal("1");
const [editSchedStartsLocal, setEditSchedStartsLocal] = createSignal("");
const [editSchedLocationId, setEditSchedLocationId] = createSignal("");
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
const [editSchedActive, setEditSchedActive] = createSignal(true);
const [schedEditSaving, setSchedEditSaving] = createSignal(false);
const [editingCapSessionId, setEditingCapSessionId] = createSignal<string | null>(null);
const [editCapValue, setEditCapValue] = createSignal(0);
const [capSaving, setCapSaving] = createSignal(false);
// Session ID whose capacity shrink hit the confirmed-seat floor — the row
// switches to an explicit "set anyway" confirm before resubmitting.
const [capOverbookConfirm, setCapOverbookConfirm] = createSignal<string | null>(null);
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
// Booking IDs whose promote attempt hit a full session — awaiting the
// explicit "confirm anyway" over-capacity resubmit.
const [promoteArmed, setPromoteArmed] = createSignal<Set<string>>(new Set());
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
const [locations, { refetch: refetchLocations }] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "z1", name: cs() ? "Hlavní sál" : "Main Hall", type: "hall", capacity: 20 },
{ id: "z2", name: cs() ? "Soukromá místnost A" : "Private Room A", type: "private", capacity: 4 },
{ id: "z3", name: cs() ? "Ošetřovna" : "Treatment Room", type: "room", capacity: 2 },
];
}
const response = await (apiClient as any).GET("/v1/catalog/locations", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [blockedDays, { refetch: refetchBlocked }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "blocked") return [];
if (bearer.startsWith("demo.")) return [];
const response = await (apiClient as any).GET("/v1/catalog/blocked-days", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [staffMembers] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || (tab !== "blocked" && tab !== "hours")) 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(), hoursScope()] as const,
async ([bearer, tab, scope]) => {
if (!bearer || tab !== "hours") return [];
if (bearer.startsWith("demo.")) {
if (scope) {
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d >= 1 && d <= 5 ? "08:00" : "", close: d >= 1 && d <= 5 ? "14:00" : "", isOpen: d >= 1 && d <= 5 }));
}
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ dayOfWeek: d, open: d === 0 ? "" : "09:00", close: d === 0 ? "" : "18:00", isOpen: d !== 0 }));
}
const url = scope ? `/v1/catalog/staff/${scope}/working-hours` : "/v1/catalog/working-hours";
const response = await (apiClient as any).GET(url, authHeaders());
const rows = (response.data as any[]) ?? [];
return [0, 1, 2, 3, 4, 5, 6].map((d) => {
const row = rows.find((r) => r.dayOfWeek === d);
return { dayOfWeek: d, open: (row?.open ?? "").slice(0, 5), close: (row?.close ?? "").slice(0, 5), isOpen: row?.isOpen ?? false };
});
},
);
const [classTemplates, { refetch: refetchTemplates }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
return [{ id: "ct1", title: cs() ? "Jóga pro začátečníky" : "Beginner yoga", durationMinutes: 60, capacity: 8 }];
}
const response = await (apiClient as any).GET("/v1/catalog/class-templates", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [classSessions, { refetch: refetchSessions }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
const start = new Date(Date.now() + 3 * 86400000);
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3, waitlistCount: 2 }];
}
const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [classSchedules, { refetch: refetchSchedules }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
];
}
const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders());
return (response.data as any[]) ?? [];
},
);
const weekdayName = (dow: number) =>
data.i18n.t(`dashboard.class.dow.${dow}`) as string;
const [roster, { refetch: refetchRoster }] = createResource(
() => [data.token(), expandedSessionId()] as const,
async ([bearer, sessionId]) => {
if (!bearer || !sessionId) return null;
if (bearer.startsWith("demo.")) {
const start = new Date(Date.now() + 3 * 86400000);
const mk = (id: string, name: string, email: string, status: string, pos = 0) => ({
bookingId: id, reference: `BOK-${id.slice(-3).toUpperCase()}`, customerName: name, customerEmail: email,
status, waitlistPosition: pos, createdAt: start.toISOString(),
});
return {
sessionId,
templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga",
locationName: cs() ? "Hlavní sál" : "Main Hall",
startsAt: start.toISOString(),
endsAt: new Date(start.getTime() + 3600000).toISOString(),
capacity: 8,
confirmed: [
mk("roster-001", cs() ? "Martina Nováková" : "Martina Novakova", "[email protected]", "confirmed"),
mk("roster-002", "David Svoboda", "[email protected]", "confirmed"),
mk("roster-003", "Alice Johnson", "[email protected]", "confirmed"),
],
waitlist: [
mk("roster-004", cs() ? "Jana Kovářová" : "Jana Kovarova", "[email protected]", "waitlisted", 1),
mk("roster-005", "Bob Smith", "[email protected]", "waitlisted", 2),
],
};
}
const response = await (apiClient as any).GET(`/v1/catalog/class-sessions/${sessionId}/roster`, authHeaders());
return response.data ?? null;
},
);
const handlePromote = async (bookingId: string, allowOverCapacity: boolean) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.t("dashboard.demo.noModify"));
return;
}
setPromoteBusy(bookingId);
try {
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${bookingId}/promote`, {
...authHeaders(),
body: { allowOverCapacity },
});
if (response.error) {
if (response.response?.status === 409 && !allowOverCapacity) {
setPromoteArmed((prev) => new Set(prev).add(bookingId));
return;
}
throw new Error("promote failed");
}
setPromoteArmed((prev) => { const next = new Set(prev); next.delete(bookingId); return next; });
void refetchRoster(); void refetchSessions(); void data.refetchSummary();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.class.promoteFailed"));
} finally {
setPromoteBusy(null);
}
};
const resolvedLocations = () => locations() ?? [];
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
const handleAddZone = async () => {
const bearer = data.token();
if (!newZoneName().trim()) return;
if (!canAddLocation()) { setShowUpgradePrompt(true); setShowAddZone(false); return; }
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.t("dashboard.demo.noSave"));
return;
}
try {
await (apiClient as any).POST("/v1/catalog/locations", {
...authHeaders(),
body: { name: newZoneName().trim(), type: newZoneType(), capacity: newZoneCapacity() },
});
setNewZoneName(""); setNewZoneType("room"); setNewZoneCapacity(10); setShowAddZone(false);
void refetchLocations();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.zone.saveFailed"));
}
};
const handleDeleteZone = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noDelete")); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/locations/${id}`, authHeaders());
void refetchLocations();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.deleteFailed"));
}
};
const handleAddBlockedDay = async () => {
const bearer = data.token();
if (!newBlockedDate()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noSave")); return; }
setBlockedSaving(true);
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() || (data.i18n.t("dashboard.day.closed")),
...(newBlockedStaffId() ? { staffId: newBlockedStaffId() } : {}),
},
});
setNewBlockedDate(""); setNewBlockedReason(""); setNewBlockedStaffId("");
void refetchBlocked();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.saveFailed"));
} finally {
setBlockedSaving(false);
}
};
const handleDeleteBlockedDay = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) return;
try {
await (apiClient as any).DELETE(`/v1/catalog/blocked-days/${id}`, authHeaders());
void refetchBlocked();
} catch { /* noop */ }
};
const handleSaveHours = async (day: { dayOfWeek: number; open: string; close: string; isOpen: boolean }) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noSave")); return; }
setHoursSaving(day.dayOfWeek);
try {
const scope = hoursScope();
const url = scope ? `/v1/catalog/staff/${scope}/working-hours/${day.dayOfWeek}` : `/v1/catalog/working-hours/${day.dayOfWeek}`;
await (apiClient as any).PUT(url, {
...authHeaders(),
body: { open: day.open, close: day.close, isOpen: day.isOpen },
});
void refetchHours();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.saveFailed"));
} finally {
setHoursSaving(null);
}
};
const resetTemplateForm = () => {
setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); setEditingTemplateId(null);
};
const handleSaveTemplate = async () => {
const bearer = data.token();
if (!newTemplateTitle().trim()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noSave")); return; }
setTemplateSaving(true);
try {
const body = { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() };
const editing = editingTemplateId();
if (editing) {
await (apiClient as any).PUT(`/v1/catalog/class-templates/${editing}`, { ...authHeaders(), body });
} else {
await (apiClient as any).POST("/v1/catalog/class-templates", { ...authHeaders(), body });
}
resetTemplateForm();
void refetchTemplates(); void refetchSessions();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.saveFailed"));
} finally {
setTemplateSaving(false);
}
};
const handleEditTemplate = (tpl: any) => {
setNewTemplateTitle(tpl.title);
setNewTemplateDuration(tpl.durationMinutes);
setNewTemplateCapacity(tpl.capacity);
setEditingTemplateId(tpl.id);
};
const handleDeleteTemplate = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noDelete")); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/class-templates/${id}`, authHeaders());
void refetchTemplates(); void refetchSessions();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.deleteFailed"));
}
};
const handleAddSession = async () => {
const bearer = data.token();
if (!newSessionTemplateId() || !newSessionStartsAt()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noSave")); return; }
setSessionSaving(true);
try {
await (apiClient as any).POST("/v1/catalog/class-sessions", {
...authHeaders(),
body: {
templateId: newSessionTemplateId(),
startsAt: new Date(newSessionStartsAt()).toISOString(),
...(newSessionLocationId() ? { locationId: newSessionLocationId() } : {}),
},
});
setNewSessionTemplateId(""); setNewSessionLocationId(""); setNewSessionStartsAt("");
void refetchSessions();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.saveFailed"));
} finally {
setSessionSaving(false);
}
};
const handleDeleteSession = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noDelete")); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/class-sessions/${id}`, authHeaders());
void refetchSessions();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.deleteFailed"));
}
};
const handleAddSchedule = async () => {
const bearer = data.token();
if (!newSchedTemplateId() || !newSchedStartsLocal()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noSave")); return; }
setSchedSaving(true);
try {
await (apiClient as any).POST("/v1/catalog/class-schedules", {
...authHeaders(),
body: {
templateId: newSchedTemplateId(),
dayOfWeek: parseInt(newSchedDayOfWeek(), 10),
startsLocal: newSchedStartsLocal(),
...(newSchedLocationId() ? { locationId: newSchedLocationId() } : {}),
},
});
setNewSchedTemplateId(""); setNewSchedStartsLocal(""); setNewSchedLocationId("");
void refetchSchedules();
void refetchSessions();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.saveFailed"));
} finally {
setSchedSaving(false);
}
};
const startEditSchedule = (sch: any) => {
setEditingSchedId(sch.id);
setEditSchedDayOfWeek(String(sch.dayOfWeek));
setEditSchedStartsLocal(String(sch.startsLocal).slice(0, 5));
setEditSchedLocationId(sch.locationId ?? "");
setEditSchedApplyFuture(true);
setEditSchedActive(sch.active !== false);
};
const handleSaveSchedule = async () => {
const bearer = data.token();
const id = editingSchedId();
if (!id) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noSave")); return; }
setSchedEditSaving(true);
try {
await (apiClient as any).PATCH(`/v1/catalog/class-schedules/${id}`, {
...authHeaders(),
body: {
dayOfWeek: parseInt(editSchedDayOfWeek(), 10),
startsLocal: editSchedStartsLocal(),
locationId: editSchedLocationId(),
active: editSchedActive(),
applyToFuture: editSchedApplyFuture(),
},
});
setEditingSchedId(null);
void refetchSchedules();
void refetchSessions();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.saveFailed"));
} finally {
setSchedEditSaving(false);
}
};
const startEditCapacity = (session: any) => {
setEditingCapSessionId(session.id);
setEditCapValue(session.capacity);
setCapOverbookConfirm(null);
};
const handleSaveCapacity = async (session: any, allowOverCapacity: boolean) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noSave")); return; }
setCapSaving(true);
try {
const response = await (apiClient as any).PATCH(`/v1/catalog/class-sessions/${session.id}`, {
...authHeaders(),
body: { capacity: editCapValue(), allowOverCapacity },
});
if (response.response?.status === 409 && !allowOverCapacity) {
setCapOverbookConfirm(session.id);
return;
}
setEditingCapSessionId(null);
setCapOverbookConfirm(null);
void refetchSessions();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.saveFailed"));
} finally {
setCapSaving(false);
}
};
const handleDeleteSchedule = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(data.i18n.t("dashboard.demo.noDelete")); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/class-schedules/${id}`, authHeaders());
void refetchSchedules();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.deleteFailed"));
}
};
const typeLabel = (type: string) => {
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
return map[type] || type;
};
const dayLabel = (d: number) => {
const labels = cs()
? ["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"]
: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
return labels[d];
};
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.zones")}</h1>
<p class="text-ink-muted mt-1">{resolvedLocations().length} {cs() ? "zón" : "locations"}</p>
</div>
</div>
<div class="flex flex-wrap gap-2 p-1 bg-canvas-subtle rounded-xl w-fit max-w-full">
{[
{ id: "zones", label: data.i18n.t("dashboard.zones"), icon: MapPinIcon },
{ id: "blocked", label: data.i18n.t("dashboard.zone.blockedDays"), icon: BanIcon },
{ id: "hours", label: data.i18n.t("dashboard.zone.workingHours"), icon: ClockIcon },
{ id: "classes", label: data.i18n.t("dashboard.classes"), icon: UsersIcon },
].map((tab) => (
<button onClick={() => setActiveTab(tab.id as any)} class={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${activeTab() === tab.id ? "bg-canvas text-accent shadow-sm" : "text-ink-muted hover:text-ink"}`}>
<tab.icon /> {tab.label}
</button>
))}
</div>
{activeTab() === "zones" && (
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border flex items-center justify-between">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zones")}</h3>
<Show when={canAddLocation()} 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-60 cursor-not-allowed">
<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={() => setShowAddZone(true)} class="btn-primary text-sm"><PlusIcon /> {data.i18n.t("dashboard.zone.add")}</button>
</Show>
</div>
<Show when={showAddZone()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{data.i18n.t("dashboard.zone.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input type="text" label={data.i18n.t("dashboard.zone.name")} value={newZoneName()} onInput={(e) => setNewZoneName(e.currentTarget.value)} placeholder={cs() ? "např. Hlavní sál" : "e.g. Main Hall"} />
<Select label={data.i18n.t("dashboard.zone.type")} value={newZoneType()} onChange={(v) => setNewZoneType(v)} options={[{ value: "room", label: data.i18n.t("dashboard.zone.rooms") }, { value: "private", label: data.i18n.t("dashboard.zone.private") }, { value: "hall", label: data.i18n.t("dashboard.zone.hall") }]} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newZoneCapacity()} onInput={(e) => setNewZoneCapacity(parseInt(e.currentTarget.value) || 10)} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={() => void handleAddZone()} class="btn-primary text-sm">{data.i18n.t("dashboard.settings.save")}</button>
<button onClick={() => setShowAddZone(false)} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<Show when={resolvedLocations().length > 0} fallback={
<div class="p-12 text-center">
<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.zone.addFirst")}</p>
</div>
}>
<div class="divide-y divide-border/60">
{resolvedLocations().map((zone: any) => (
<div class="flex items-center justify-between p-6 hover:bg-canvas-subtle/30 transition-colors">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><MapPinIcon /></div>
<div>
<h4 class="font-medium text-ink">{zone.name}</h4>
<p class="text-sm text-ink-muted">{typeLabel(zone.type)} &bull; {data.i18n.t("dashboard.zone.capacity")}: {zone.capacity}</p>
</div>
</div>
<button onClick={() => void handleDeleteZone(zone.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.action.delete")}>
<Trash2Icon />
</button>
</div>
))}
</div>
</Show>
</div>
)}
{activeTab() === "blocked" && (
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.blockedDays")}</h3>
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.blockedDay.hint")}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Input type="date" label={data.i18n.t("dashboard.blockedDay.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={data.i18n.t("dashboard.blockedDay.placeholder")} />
<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")}
</button>
</div>
</div>
</div>
<Show when={(blockedDays() ?? []).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"><BanIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.blockedDay.empty")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={blockedDays() ?? []}>
{(day: any) => (
<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 || "—"}
<Show when={staffName(day.staffId)}>
<span class="text-ink-subtle"> &bull; {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>
)}
</For>
</div>
</Show>
</div>
)}
{activeTab() === "hours" && (
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.zone.workingHours")}</h3>
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.availability.hint")}</p>
</div>
<div class="w-full sm:w-56 shrink-0">
<Select
label={data.i18n.t("dashboard.zone.staff")}
value={hoursScope()}
onChange={(v) => setHoursScope(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.zone.businessHours") },
...(staffMembers() ?? []).map((m: any) => ({ value: m.id, label: m.displayName })),
]}
/>
</div>
</div>
<div class="divide-y divide-border/60">
<For each={workingHours() ?? []}>
{(day) => {
const isOpen = () => day.isOpen;
const setField = (patch: Partial<typeof day>) => {
const updated = { ...day, ...patch };
mutateHours((workingHours() ?? []).map((d) => (d.dayOfWeek === day.dayOfWeek ? updated : d)));
};
return (
<div class="flex flex-col sm:flex-row sm:items-center gap-3 p-4 px-6">
<div class="w-32 shrink-0 font-medium text-ink">{dayLabel(day.dayOfWeek)}</div>
<label class="flex items-center gap-2 text-sm text-ink-muted shrink-0">
<input
type="checkbox"
checked={isOpen()}
onChange={(e) => setField({ isOpen: e.currentTarget.checked, open: e.currentTarget.checked ? (day.open || "09:00") : day.open, close: e.currentTarget.checked ? (day.close || "18:00") : day.close })}
class="rounded border-border accent-[#c25e3a]"
/>
{data.i18n.t("dashboard.day.open")}
</label>
<Show when={isOpen()}>
<div class="flex items-center gap-2">
<input type="time" value={day.open} onInput={(e) => setField({ open: e.currentTarget.value })} class="px-3 py-2 bg-canvas-subtle border border-border rounded-lg text-sm text-ink" />
<span class="text-ink-muted">–</span>
<input type="time" value={day.close} onInput={(e) => setField({ close: e.currentTarget.value })} class="px-3 py-2 bg-canvas-subtle border border-border rounded-lg text-sm text-ink" />
</div>
</Show>
<button onClick={() => void handleSaveHours(workingHours()?.find((d) => d.dayOfWeek === day.dayOfWeek) ?? day)} disabled={hoursSaving() === day.dayOfWeek} class="sm:ml-auto px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-60">
{hoursSaving() === day.dayOfWeek ? "…" : data.i18n.t("dashboard.settings.save")}
</button>
</div>
);
}}
</For>
</div>
</div>
)}
{activeTab() === "classes" && (
<div class="space-y-6">
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.templates")}</h3>
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.class.sessionHint")}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Input type="text" label={data.i18n.t("dashboard.class.title")} value={newTemplateTitle()} onInput={(e) => setNewTemplateTitle(e.currentTarget.value)} placeholder={data.i18n.t("dashboard.class.titlePlaceholder")} />
<Input type="number" label={data.i18n.t("dashboard.class.durationMin")} value={newTemplateDuration()} onInput={(e) => setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} />
<Input type="number" label={data.i18n.t("dashboard.zone.capacity")} value={newTemplateCapacity()} onInput={(e) => setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} />
<div class="flex items-end">
<button onClick={() => void handleSaveTemplate()} disabled={templateSaving() || !newTemplateTitle().trim()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{templateSaving() ? data.i18n.t("dashboard.saving") : editingTemplateId() ? data.i18n.t("dashboard.class.saveChanges") : data.i18n.t("dashboard.class.addTemplate")}
</button>
<Show when={editingTemplateId()}>
<button onClick={resetTemplateForm} class="text-sm text-ink-muted hover:text-ink px-3 py-2">
{data.i18n.t("dashboard.cancel")}
</button>
</Show>
</div>
</div>
</div>
<Show when={(classTemplates() ?? []).length > 0} fallback={
<div class="p-10 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noTemplates")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={classTemplates() ?? []}>
{(tpl: any) => (
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><UsersIcon /></div>
<div>
<p class="font-medium text-ink">{tpl.title}</p>
<p class="text-sm text-ink-muted">{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} &bull; {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button onClick={() => handleEditTemplate(tpl)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors"><PencilIcon /></button>
<button onClick={() => void handleDeleteTemplate(tpl.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.recurring")}</h3>
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.class.recurringHint")}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Select
label={data.i18n.t("dashboard.class.template")}
value={newSchedTemplateId()}
onChange={(v) => setNewSchedTemplateId(v)}
options={[
{ value: "", label: "—" },
...(classTemplates() ?? []).map((t: any) => ({ value: t.id, label: t.title })),
]}
/>
<Select
label={data.i18n.t("dashboard.class.weekday")}
value={newSchedDayOfWeek()}
onChange={(v) => setNewSchedDayOfWeek(v)}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekdayName(d) }))}
/>
<Input type="time" label={data.i18n.t("dashboard.class.startsLocal")} value={newSchedStartsLocal()} onInput={(e) => setNewSchedStartsLocal(e.currentTarget.value)} />
<Select
label={data.i18n.t("dashboard.class.location")}
value={newSchedLocationId()}
onChange={(v) => setNewSchedLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
<div class="flex items-end">
<button onClick={() => void handleAddSchedule()} disabled={schedSaving() || !newSchedTemplateId() || !newSchedStartsLocal()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{schedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addSchedule")}
</button>
</div>
</div>
</div>
<Show when={(classSchedules() ?? []).length > 0} fallback={
<div class="p-10 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noSchedules")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={classSchedules() ?? []}>
{(sch: any) => (
<Show
when={editingSchedId() === sch.id}
fallback={
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<p class="font-medium text-ink">{sch.templateTitle}</p>
<p class="text-sm text-ink-muted">
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
{sch.locationName ? ` • ${sch.locationName}` : ""}
{!sch.active ? ` • ${data.i18n.t("dashboard.class.inactive")}` : ""}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<button onClick={() => startEditSchedule(sch)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle/40 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.class.editSchedule")}><PencilIcon /></button>
<button onClick={() => void handleDeleteSchedule(sch.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.action.delete")}><Trash2Icon /></button>
</div>
</div>
}
>
<div class="p-4 px-6 bg-canvas-subtle/40">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Select
label={data.i18n.t("dashboard.class.weekday")}
value={editSchedDayOfWeek()}
onChange={(v) => setEditSchedDayOfWeek(v)}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekdayName(d) }))}
/>
<Input type="time" label={data.i18n.t("dashboard.class.startsLocal")} value={editSchedStartsLocal()} onInput={(e) => setEditSchedStartsLocal(e.currentTarget.value)} />
<Select
label={data.i18n.t("dashboard.class.location")}
value={editSchedLocationId()}
onChange={(v) => setEditSchedLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
</div>
<label class="flex items-start gap-2 mt-4 cursor-pointer">
<input type="checkbox" checked={editSchedActive()} onChange={(e) => setEditSchedActive(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.ruleActive")}</span>
</label>
<label class="flex items-start gap-2 mt-3 cursor-pointer">
<input type="checkbox" checked={editSchedApplyFuture()} onChange={(e) => setEditSchedApplyFuture(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
<span>
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.applyToFuture")}</span>
<span class="block text-xs text-ink-muted">{data.i18n.t("dashboard.class.applyToFutureHint")}</span>
</span>
</label>
<div class="flex items-center gap-2 mt-4">
<button onClick={() => void handleSaveSchedule()} disabled={schedEditSaving() || !editSchedStartsLocal()} class="btn-primary text-sm disabled:opacity-60">
<CheckIcon /> {schedEditSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
</button>
<button onClick={() => setEditingSchedId(null)} class="btn-secondary text-sm">
<XIcon /> {data.i18n.t("dashboard.cancel")}
</button>
</div>
</div>
</Show>
)}
</For>
</div>
</Show>
</div>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.sessions")}</h3>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Select
label={data.i18n.t("dashboard.class.template")}
value={newSessionTemplateId()}
onChange={(v) => setNewSessionTemplateId(v)}
options={[
{ value: "", label: "—" },
...(classTemplates() ?? []).map((t: any) => ({ value: t.id, label: t.title })),
]}
/>
<Select
label={data.i18n.t("dashboard.class.location")}
value={newSessionLocationId()}
onChange={(v) => setNewSessionLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
<Input type="datetime-local" label={data.i18n.t("dashboard.class.startsAt")} value={newSessionStartsAt()} onInput={(e) => setNewSessionStartsAt(e.currentTarget.value)} />
<div class="flex items-end">
<button onClick={() => void handleAddSession()} disabled={sessionSaving() || !newSessionTemplateId() || !newSessionStartsAt()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{sessionSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addSession")}
</button>
</div>
</div>
</div>
<Show when={(classSessions() ?? []).length > 0} fallback={
<div class="p-10 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noSessions")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={classSessions() ?? []}>
{(session: any) => {
const expanded = () => expandedSessionId() === session.id;
return (
<div>
<div class="flex items-center justify-between gap-3 p-4 px-6">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<p class="font-medium text-ink">{session.templateTitle}</p>
<Show when={session.waitlistCount > 0}>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-warning-subtle text-warning">
{session.waitlistCount} {data.i18n.t("dashboard.class.waiting")}
</span>
</Show>
</div>
<p class="text-sm text-ink-muted">
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
{session.locationName ? ` • ${session.locationName}` : ""}
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
onClick={() => setExpandedSessionId(expanded() ? null : session.id)}
class={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${expanded() ? "bg-accent-subtle text-accent" : "text-ink-muted hover:text-ink hover:bg-canvas-subtle"}`}
>
{expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")}
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
</button>
<button onClick={() => startEditCapacity(session)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle/40 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.class.editCapacity")}><PencilIcon /></button>
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.action.delete")}><Trash2Icon /></button>
</div>
</div>
<Show when={editingCapSessionId() === session.id}>
<div class="px-6 pb-4 animate-fade-in">
<div class="flex flex-wrap items-end gap-3 rounded-xl border border-border/60 bg-canvas-subtle/30 p-4">
<Input type="number" label={data.i18n.t("dashboard.class.capacityLabel")} value={String(editCapValue())} min={1} onInput={(e) => setEditCapValue(parseInt(e.currentTarget.value, 10) || 0)} />
<button onClick={() => void handleSaveCapacity(session, capOverbookConfirm() === session.id)} disabled={capSaving() || editCapValue() < 1} class="btn-primary text-sm disabled:opacity-60">
<CheckIcon /> {capSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
</button>
<button onClick={() => { setEditingCapSessionId(null); setCapOverbookConfirm(null); }} class="btn-secondary text-sm">
<XIcon /> {data.i18n.t("dashboard.cancel")}
</button>
<Show when={capOverbookConfirm() === session.id}>
<p class="w-full text-sm text-warning font-medium">{data.i18n.t("dashboard.class.capacityOverbookHint")}</p>
</Show>
</div>
</div>
</Show>
<Show when={expanded()}>
<div class="px-6 pb-5 animate-fade-in">
<div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden">
<Show when={!roster.loading} fallback={
<div class="p-8 text-center text-sm text-ink-muted animate-pulse">…</div>
}>
<div class="grid sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-border/60">
<div class="p-4">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
{data.i18n.t("dashboard.class.attendees")} ({(roster()?.confirmed ?? []).length}/{roster()?.capacity ?? session.capacity})
</p>
<Show when={(roster()?.confirmed ?? []).length > 0} fallback={
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noAttendees")}</p>
}>
<div class="space-y-2">
<For each={roster()?.confirmed ?? []}>
{(entry: any) => (
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-[11px] font-bold text-accent shrink-0">
{getInitials(entry.customerName)}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
<div class="p-4">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
{data.i18n.t("dashboard.class.waitlist")} ({(roster()?.waitlist ?? []).length})
</p>
<Show when={(roster()?.waitlist ?? []).length > 0} fallback={
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noWaitlist")}</p>
}>
<div class="space-y-2">
<For each={roster()?.waitlist ?? []}>
{(entry: any) => (
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-warning-subtle flex items-center justify-center text-[11px] font-bold text-warning shrink-0">
#{entry.waitlistPosition}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
</div>
<Show
when={!promoteArmed().has(entry.bookingId)}
fallback={
<button
onClick={() => void handlePromote(entry.bookingId, true)}
disabled={promoteBusy() === entry.bookingId}
title={data.i18n.t("dashboard.class.promoteFull")}
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-warning text-canvas hover:opacity-90 transition-opacity disabled:opacity-60"
>
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promoteConfirm")}
</button>
}
>
<button
onClick={() => void handlePromote(entry.bookingId, false)}
disabled={promoteBusy() === entry.bookingId}
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-accent text-canvas hover:bg-accent-hover transition-colors disabled:opacity-60"
>
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promote")}
</button>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
</div>
</div>
</Show>
</div>
);
}}
</For>
</div>
</Show>
</div>
</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"><SparklesIcon /></div>
<div class="flex-1">
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.locationLimitReached")}</h4>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.zone.limitHint")}</p>
<div class="flex gap-3 mt-3">
<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 ZonesRoute() {
return (
<DashboardLayout>
<ZonesPage />
</DashboardLayout>
);
}