mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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>
1069 lines
59 KiB
TypeScript
1069 lines
59 KiB
TypeScript
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)} • {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"> • {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")} • {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>
|
||
);
|
||
}
|