Compare commits

...
5 Commits
Author SHA1 Message Date
popertotsandSisyphus 5f1f6c4b44 style: standardise select styling in DiscountsManagement, HolidayHours, TodayCalendar
CI / Go vulnerabilities (push) Successful in 34s
CI / Frontend lint & types (push) Failing after 1m7s
CI / Tests (push) Successful in 1m29s
CI / Race detector (push) Successful in 3m25s
Update select element classes to consistent h-9, shadow-xs, bg-background, focus-visible ring styling.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 22:26:38 +01:00
popertotsandSisyphus 5616357269 style: standardise input styling in booking create, business settings, custom services, patch test, walk-in create
Update input element classes to consistent h-9, py-1, shadow-xs, focus-visible border-ring with ring-[3px] and ring-ring/50 for visual consistency across admin components.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 22:26:28 +01:00
popertotsandSisyphus b7fa22b018 refactor: accept defaultHours prop in WeeklySchedule and TimeBlockers
Add optional defaultHours prop to accept pre-fetched data from parent page. Standardise select element styling with shadow-xs, focus-visible ring, and consistent padding.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 22:26:18 +01:00
popertotsandSisyphus 56eda0ead5 refactor: accept services prop in PatchTestsManagement and ServicesManagement
Add optional services prop so the page can pass pre-fetched data, avoiding duplicate API calls. Fall back to self-fetching when prop is not provided.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 22:26:08 +01:00
popertotsandSisyphus 42c3b65a27 feat: add collapsible sections and lifted data fetching to admin page
Add independently toggleable collapsible sections for scheduling, customers, services, promotions, settings. Lift services and default-hours fetching to page level to avoid duplicate API calls. Add responsive breakpoint detection — sections default expanded on desktop, collapsed on mobile.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 22:25:49 +01:00
13 changed files with 368 additions and 223 deletions
@@ -1420,7 +1420,7 @@
(customServiceErrors.duration_minutes = validateCsDuration(
newCustomService.duration_minutes
))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {customServiceErrors.duration_minutes
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 {customServiceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
@@ -623,7 +623,7 @@
<Label.Root for="voucher_type">Voucher Type</Label.Root>
<select
id="voucher_type"
class="flex h-10 w-full rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={form.voucher_type}
onchange={() => validateField('voucher_type')}
>
@@ -483,7 +483,7 @@
id="cs-duration"
bind:value={newService.duration_minutes}
onchange={() => validateField('duration_minutes')}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {serviceErrors.duration_minutes
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 {serviceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
@@ -499,7 +499,7 @@
<select
id="dc-scope"
bind:value={form.scope}
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="all_bookings">All bookings</option>
<option value="first_booking_only">First booking only</option>
@@ -534,7 +534,7 @@
<select
id="dc-ms-type"
bind:value={form.milestone_type}
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="per_user_booking_count">Per-user booking count</option>
<option value="global_booking_count">Global booking count</option>
@@ -563,7 +563,7 @@
<select
id="dc-ms-unit"
bind:value={form.milestone_unit}
class="flex h-9 w-full rounded-md border border-gray-300 bg-transparent px-3 py-1 text-sm shadow-sm"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="months">Months</option>
<option value="years">Years</option>
@@ -568,7 +568,7 @@
<select
bind:value={row.start_time}
disabled={!row.is_open}
class="w-24 text-sm"
class="flex h-9 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each Array(24) as _, hour (hour)}
{#each TIME15 as min (min)}
@@ -580,7 +580,7 @@
</select>
</td>
<td class="py-2">
<select bind:value={row.end_time} disabled={!row.is_open} class="w-24 text-sm">
<select bind:value={row.end_time} disabled={!row.is_open} class="flex h-9 w-24 rounded-md border border-input bg-background px-2 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50">
{#each Array(24) as _, hour (hour)}
{#each TIME15 as min (min)}
<option value="{String(hour).padStart(2, '0')}:{min}"
@@ -116,7 +116,7 @@
<select
id="service-select"
bind:value={selectedServiceId}
class="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
class="mt-1 flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each eligibleServices as service (service.id)}
<option value={service.id}>
@@ -23,6 +23,12 @@
service_ids: string[];
};
interface Props {
services?: Service[];
}
let { services: servicesProp }: Props = $props();
let patchTests = $state<PatchTest[]>([]);
let availableServices = $state<Service[]>([]);
let loading = $state(true);
@@ -99,26 +105,30 @@
async function fetchData() {
loading = true;
try {
const [ptRes, svcRes] = await Promise.all([
fetch('/api/admin/patch-tests', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch('/api/admin/services', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
})
]);
const ptRes = await fetch('/api/admin/patch-tests', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (ptRes.ok && svcRes.ok) {
if (ptRes.ok) {
patchTests = await ptRes.json();
const svcData = await svcRes.json();
const uniqueSvc = new SvelteMap<string, Service>();
svcData.forEach((s: Service) => {
if (s.id) uniqueSvc.set(s.id, s);
});
availableServices = Array.from(uniqueSvc.values());
} else {
toast.error('Failed to load patch test data');
}
// Only fetch services if not provided via prop
if (!servicesProp) {
const svcRes = await fetch('/api/admin/services', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (svcRes.ok) {
const svcData = await svcRes.json();
const uniqueSvc = new SvelteMap<string, Service>();
svcData.forEach((s: Service) => {
if (s.id) uniqueSvc.set(s.id, s);
});
availableServices = Array.from(uniqueSvc.values());
}
}
} catch (err) {
console.error(err);
toast.error('Network error loading data');
@@ -241,6 +251,13 @@
}
}
// Reactively update availableServices when the parent provides services via prop
$effect(() => {
if (servicesProp !== undefined) {
availableServices = servicesProp;
}
});
$effect(() => {
fetchData();
});
@@ -24,6 +24,13 @@
created_by?: string;
};
interface Props {
services?: Service[];
onRefresh?: () => Promise<void>;
}
let { services: servicesProp, onRefresh }: Props = $props();
const durationOptions = Array.from({ length: 32 }, (_, i) => (i + 1) * 15);
let services = $state<Service[]>([]);
@@ -139,6 +146,12 @@
// =============== API Functions ===============
async function fetchServices() {
// If the parent provides services via prop, delegate refresh to parent
if (servicesProp && onRefresh) {
await onRefresh();
return;
}
servicesLoading = true;
try {
const response = await fetch('/api/admin/services', {
@@ -306,8 +319,18 @@
}
// =============== Lifecycle ===============
// Single effect: sync from prop when provided, self-fetch when not.
// Combined into one effect to prevent the reactive loop where
// fetchServices() → onRefresh() → parent updates → servicesProp changes → effect re-runs.
$effect(() => {
fetchServices();
if (servicesProp !== undefined) {
if (servicesProp.length > 0) {
services = servicesProp;
}
servicesLoading = false;
} else {
fetchServices();
}
});
</script>
@@ -570,7 +593,7 @@
id="service-duration"
bind:value={newService.duration_minutes}
onblur={validateDurationField}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {serviceErrors.duration_minutes
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 {serviceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
@@ -53,9 +53,10 @@
openUserModal?: (userId: string) => void;
openBookingModal?: (bookingId: string) => void;
rescheduleVersion?: number;
defaultHours?: WorkingHourRow[];
}
const { openUserModal, openBookingModal, rescheduleVersion = 0 }: Props = $props();
const { openUserModal, openBookingModal, rescheduleVersion = 0, defaultHours: defaultHoursProp }: Props = $props();
const PAGE_SIZE = 5;
@@ -501,7 +502,17 @@
$effect(() => {
fetchBlockers();
fetchDefaultHours();
});
$effect(() => {
if (defaultHoursProp !== undefined) {
if (defaultHoursProp.length > 0) {
defaultHours = defaultHoursProp;
}
hoursLoading = false;
} else {
fetchDefaultHours();
}
});
$effect(() => {
@@ -763,7 +774,7 @@
</p>
{:else}
<select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={startSelectValue}
onchange={() => {
const p = parseSelectValue(startSelectValue);
@@ -797,9 +808,9 @@
{:else if availableEndOptions.length === 0}
<p class="text-sm text-gray-400">No available end time after selected start</p>
{:else}
<select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
bind:value={endSelectValue}
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={endSelectValue}
onchange={() => {
const p = parseSelectValue(endSelectValue);
endHour = p.hour;
@@ -981,7 +981,7 @@
(customServiceErrors.duration_minutes = validateCsDuration(
newCustomService.duration_minutes
))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {customServiceErrors.duration_minutes
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 {customServiceErrors.duration_minutes
? 'border-red-500'
: ''}"
>
@@ -13,6 +13,13 @@
const TIME15 = ['00', '15', '30', '45'];
// =============== Props ===============
interface Props {
defaultHours?: Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }>;
}
let { defaultHours: defaultHoursProp }: Props = $props();
// =============== Types ===============
type WorkingHourRow = {
weekday: number;
@@ -192,7 +199,21 @@
// =============== Effects ===============
$effect(() => {
if (browser) {
if (defaultHoursProp !== undefined) {
// Parent provides data via prop — map camelCase to display format
if (defaultHoursProp.length > 0) {
defaultHours = defaultHoursProp.map(
(hour: { weekday: number; startTime: string; endTime: string; isOpen: boolean }) => ({
weekday: hour.weekday,
start_time: formatTime(hour.startTime),
end_time: formatTime(hour.endTime),
is_open: hour.isOpen
})
);
}
defaultHoursIsLoading = false;
} else if (browser) {
// Backward compatible: self-fetch
fetchDefaultHours();
}
});
@@ -485,7 +506,7 @@
<select
bind:value={row.start_time}
disabled={!row.is_open}
class="max-w-[70px] text-sm"
class="flex h-9 max-w-[70px] rounded-md border border-input bg-background px-2 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each Array(24) as _, hour (hour)}
{#each TIME15 as min (min)}
@@ -500,7 +521,7 @@
<select
bind:value={row.end_time}
disabled={!row.is_open}
class="max-w-[70px] text-sm"
class="flex h-9 max-w-[70px] rounded-md border border-input bg-background px-2 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{#each Array(24) as _, hour (hour)}
{#each TIME15 as min (min)}
@@ -935,7 +935,7 @@
<p class="text-sm text-red-500">Closed today</p>
{:else}
<select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={startSelectValue}
onchange={() => {
const p = parseSelectValue(startSelectValue);
@@ -963,7 +963,7 @@
<p class="text-sm text-gray-400">No available end time after selected start</p>
{:else}
<select
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={endSelectValue}
onchange={() => {
const p = parseSelectValue(endSelectValue);
+258 -185
View File
@@ -19,6 +19,7 @@
import BusinessSettings from '$lib/components/admin/BusinessSettings.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
// =============== Auth & Permissions ===============
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
@@ -56,6 +57,81 @@
let selectedBookingId = $state<string | null>(null);
let rescheduleVersion = $state(0);
// =============== Collapsible Sections State ===============
// Each section is independently toggleable. State resets on page reload.
// Default expands all on desktop, collapses all on mobile.
let sectionState = $state({
scheduling: 'expanded',
customers: 'expanded',
services: 'expanded',
promotions: 'expanded',
settings: 'expanded'
});
let isMobile = $state(false);
// =============== Lifted Data Fetching ===============
// Fetch shared data once at page level to avoid duplicate API calls
let defaultHours = $state<Array<{ weekday: number; startTime: string; endTime: string; isOpen: boolean }>>([]);
let services = $state<Array<{ id: string; name: string; description: string; price: number; duration_minutes: number; is_active: boolean; patch_test_duration_hours: number; minimum_age_required: number; created_at: string; created_by?: string }>>([]);
// Responsive: reset section defaults when crossing the 768px breakpoint
$effect(() => {
if (!browser) return;
const mql = window.matchMedia('(max-width: 767px)');
isMobile = mql.matches;
function handleChange(e: MediaQueryListEvent) {
isMobile = e.matches;
for (const key in sectionState) {
sectionState[key as keyof typeof sectionState] = e.matches ? 'collapsed' : 'expanded';
}
}
mql.addEventListener('change', handleChange);
return () => mql.removeEventListener('change', handleChange);
});
// Fetch shared data once when authorized.
// Uses a plain (non-reactive) guard to prevent re-fetch loops.
// Svelte 5's $effect tracks reactive reads in the synchronous execution path,
// including inside async functions before the first `await`. Using a non-reactive
// `dataLoaded` flag prevents re-triggering when async callbacks complete.
let dataLoaded = false;
$effect(() => {
if (pageState !== 'authorized' || !browser || dataLoaded) return;
dataLoaded = true;
async function loadSharedData() {
try {
const [hoursRes, servicesRes] = await Promise.all([
fetch('/api/scheduling/default-hours', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch('/api/admin/services', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
})
]);
if (hoursRes.ok) {
defaultHours = await hoursRes.json();
}
if (servicesRes.ok) {
services = await servicesRes.json();
}
} catch (err) {
console.error('Error loading admin data:', err);
}
}
loadSharedData();
});
function openUserModal(userId: string) {
selectedUserId = userId;
showUserModal = true;
@@ -96,7 +172,6 @@
</svelte:head>
{#if pageState === 'loading'}
<!-- Full page skeleton loading -->
<div class="mx-auto max-w-6xl space-y-6 p-6">
<!-- Header Skeleton -->
<div class="mb-8 flex items-center justify-center text-center">
@@ -106,189 +181,76 @@
</div>
</div>
<!-- Image Upload Card Skeleton -->
<!-- 1. Portfolio Images -->
<div class="rounded-lg border p-6">
<div class="mb-4 space-y-2">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-48" />
</div>
<Skeleton class="h-32 w-full" />
<div class="mt-4 flex justify-end">
<Skeleton class="h-10 w-40" />
</div>
</div>
<!-- Users & Bookings Grid Skeleton -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Users Card Skeleton -->
<div class="rounded-lg border p-6">
<div class="mb-4 space-y-2">
<Skeleton class="h-6 w-20" />
<Skeleton class="h-4 w-40" />
</div>
<div class="flex gap-2">
<Skeleton class="h-10 flex-1" />
<Skeleton class="h-10 w-20" />
</div>
<div class="mt-4 space-y-2">
{#each Array(3) as _, i (i)}
<Skeleton class="h-16 w-full" />
{/each}
</div>
</div>
<!-- Bookings Card Skeleton -->
<div class="rounded-lg border p-6">
<div class="mb-4 space-y-2">
<Skeleton class="h-6 w-24" />
<Skeleton class="h-4 w-40" />
</div>
<div class="flex gap-2">
<Skeleton class="h-10 flex-1" />
<Skeleton class="h-10 w-20" />
</div>
<div class="mt-4 space-y-2">
{#each Array(3) as _, i (i)}
<Skeleton class="h-16 w-full" />
{/each}
</div>
</div>
</div>
<!-- Time Blockers Card Skeleton -->
<div class="rounded-lg border p-6">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-2">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-80" />
</div>
<Skeleton class="h-10 w-28" />
</div>
<div class="mt-4 space-y-3">
{#each Array(3) as _, i (i)}
<Skeleton class="h-16 w-full" />
{/each}
</div>
</div>
<!-- Holiday Hours Card Skeleton -->
<!-- Working Hours Card Skeleton -->
<div class="rounded-lg border p-6">
<div class="mb-4 space-y-2">
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-64" />
</div>
<div class="flex justify-between">
<div class="space-y-2">
<Skeleton class="h-6 w-40" />
<Skeleton class="h-10 w-24" />
</div>
<div class="mt-4 w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2"><Skeleton class="h-4 w-12" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
</tr>
</thead>
<tbody>
{#each Array(7) as _, i (i)}
<tr class="border-t">
<td class="py-2"><Skeleton class="h-4 w-20" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
</tr>
{/each}
</tbody>
</table>
<Skeleton class="h-4 w-56" />
</div>
<Skeleton class="mt-4 h-32 w-full rounded-lg" />
</div>
<!-- Services Management Card Skeleton -->
<!-- 2. Customers & Bookings -->
<div class="rounded-lg border p-6">
<div class="mb-4 space-y-2">
<Skeleton class="h-6 w-40" />
<Skeleton class="h-4 w-80" />
</div>
<div class="flex justify-between">
<Skeleton class="h-10 w-32" />
</div>
<div class="mt-4 hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
<th class="py-3"><Skeleton class="h-4 w-32" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-24" /></th>
</tr>
</thead>
<tbody>
{#each Array(3) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Discounts Management Card Skeleton -->
<div class="rounded-lg border p-6">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center justify-between">
<div class="space-y-2">
<Skeleton class="h-6 w-40" />
<Skeleton class="h-6 w-48" />
<Skeleton class="h-4 w-72" />
</div>
<Skeleton class="h-5 w-5" />
</div>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<Skeleton class="h-40 w-full rounded-lg" />
<Skeleton class="h-40 w-full rounded-lg" />
</div>
</div>
<!-- 3. Services & Products -->
<div class="rounded-lg border p-6">
<div class="flex items-center justify-between">
<div class="space-y-2">
<Skeleton class="h-6 w-48" />
<Skeleton class="h-4 w-64" />
</div>
<Skeleton class="h-10 w-32" />
<Skeleton class="h-5 w-5" />
</div>
<div class="mt-4 hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="py-3"><Skeleton class="h-4 w-24" /></th>
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-40" /></th>
</tr>
</thead>
<tbody>
{#each Array(2) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
<td class="py-3"><Skeleton class="h-4 w-12" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-12" /></td>
<td class="py-3">
<div class="flex justify-end gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<Skeleton class="mt-4 h-24 w-full" />
</div>
<!-- 4. Scheduling & Hours -->
<div class="rounded-lg border p-6">
<div class="flex items-center justify-between">
<div class="space-y-2">
<Skeleton class="h-6 w-44" />
<Skeleton class="h-4 w-64" />
</div>
<Skeleton class="h-5 w-5" />
</div>
<Skeleton class="mt-4 h-24 w-full" />
</div>
<!-- 5. Promotions & Finance -->
<div class="rounded-lg border p-6">
<div class="flex items-center justify-between">
<div class="space-y-2">
<Skeleton class="h-6 w-48" />
<Skeleton class="h-4 w-64" />
</div>
<Skeleton class="h-5 w-5" />
</div>
<Skeleton class="mt-4 h-24 w-full" />
</div>
<!-- 6. Business Settings -->
<div class="rounded-lg border p-6">
<div class="flex items-center justify-between">
<div class="space-y-2">
<Skeleton class="h-6 w-44" />
<Skeleton class="h-4 w-56" />
</div>
<Skeleton class="h-5 w-5" />
</div>
<Skeleton class="mt-4 h-24 w-full" />
</div>
</div>
{:else if pageState === 'authorized'}
@@ -300,20 +262,131 @@
</div>
</div>
<!-- 1. Portfolio Images -->
<ImageUpload />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<UsersCard {openUserModal} />
<BookingsCard {openBookingModal} />
<!-- 2. Customers & Bookings -->
<div class="rounded-lg border">
<button
class="flex w-full items-center justify-between bg-gray-50 px-4 py-3 transition-colors hover:bg-gray-100 {sectionState.customers === 'expanded' ? 'rounded-t-lg' : 'rounded-lg'}"
style="min-height: 44px;"
onclick={() => sectionState.customers = sectionState.customers === 'expanded' ? 'collapsed' : 'expanded'}
aria-expanded={sectionState.customers === 'expanded'}
>
<span class="font-medium text-gray-900">Customers & Bookings</span>
<ChevronDownIcon
class="h-4 w-4 text-gray-500 transition-transform duration-200 {sectionState.customers === 'expanded' ? '' : '-rotate-90'}"
/>
</button>
{#if sectionState.customers === 'expanded'}
<div class="p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<UsersCard {openUserModal} />
<BookingsCard {openBookingModal} />
</div>
</div>
{/if}
</div>
<!-- 3. Services & Products -->
<div class="rounded-lg border">
<button
class="flex w-full items-center justify-between bg-gray-50 px-4 py-3 transition-colors hover:bg-gray-100 {sectionState.services === 'expanded' ? 'rounded-t-lg' : 'rounded-lg'}"
style="min-height: 44px;"
onclick={() => sectionState.services = sectionState.services === 'expanded' ? 'collapsed' : 'expanded'}
aria-expanded={sectionState.services === 'expanded'}
>
<span class="font-medium text-gray-900">Services & Products</span>
<ChevronDownIcon
class="h-4 w-4 text-gray-500 transition-transform duration-200 {sectionState.services === 'expanded' ? '' : '-rotate-90'}"
/>
</button>
{#if sectionState.services === 'expanded'}
<div class="space-y-4 p-4">
<CustomServicesManagement />
<PatchTestsManagement services={services} />
<ServicesManagement services={services} onRefresh={async () => {
if (!browser) return;
try {
const r = await fetch('/api/admin/services', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (r.ok) {
services = await r.json();
}
} catch (err) {
console.error('Error refreshing services:', err);
}
}} />
</div>
{/if}
</div>
<!-- 4. Scheduling & Hours -->
<div class="rounded-lg border">
<button
class="flex w-full items-center justify-between bg-gray-50 px-4 py-3 transition-colors hover:bg-gray-100 {sectionState.scheduling === 'expanded' ? 'rounded-t-lg' : 'rounded-lg'}"
style="min-height: 44px;"
onclick={() => sectionState.scheduling = sectionState.scheduling === 'expanded' ? 'collapsed' : 'expanded'}
aria-expanded={sectionState.scheduling === 'expanded'}
>
<span class="font-medium text-gray-900">Scheduling & Hours</span>
<ChevronDownIcon
class="h-4 w-4 text-gray-500 transition-transform duration-200 {sectionState.scheduling === 'expanded' ? '' : '-rotate-90'}"
/>
</button>
{#if sectionState.scheduling === 'expanded'}
<div class="space-y-4 p-4">
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} defaultHours={defaultHours} />
<HolidayHours />
<WeeklySchedule defaultHours={defaultHours} />
</div>
{/if}
</div>
<!-- 5. Promotions & Finance -->
<div class="rounded-lg border">
<button
class="flex w-full items-center justify-between bg-gray-50 px-4 py-3 transition-colors hover:bg-gray-100 {sectionState.promotions === 'expanded' ? 'rounded-t-lg' : 'rounded-lg'}"
style="min-height: 44px;"
onclick={() => sectionState.promotions = sectionState.promotions === 'expanded' ? 'collapsed' : 'expanded'}
aria-expanded={sectionState.promotions === 'expanded'}
>
<span class="font-medium text-gray-900">Promotions & Finance</span>
<ChevronDownIcon
class="h-4 w-4 text-gray-500 transition-transform duration-200 {sectionState.promotions === 'expanded' ? '' : '-rotate-90'}"
/>
</button>
{#if sectionState.promotions === 'expanded'}
<div class="space-y-4 p-4">
<DiscountsManagement />
<GiftCardsManagement />
</div>
{/if}
</div>
<!-- 6. Business Settings -->
<div class="rounded-lg border">
<button
class="flex w-full items-center justify-between bg-gray-50 px-4 py-3 transition-colors hover:bg-gray-100 {sectionState.settings === 'expanded' ? 'rounded-t-lg' : 'rounded-lg'}"
style="min-height: 44px;"
onclick={() => sectionState.settings = sectionState.settings === 'expanded' ? 'collapsed' : 'expanded'}
aria-expanded={sectionState.settings === 'expanded'}
>
<span class="font-medium text-gray-900">Business Settings</span>
<ChevronDownIcon
class="h-4 w-4 text-gray-500 transition-transform duration-200 {sectionState.settings === 'expanded' ? '' : '-rotate-90'}"
/>
</button>
{#if sectionState.settings === 'expanded'}
<div class="p-4">
<BusinessSettings />
</div>
{/if}
</div>
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} />
<HolidayHours />
<WeeklySchedule />
<ServicesManagement />
<CustomServicesManagement />
<PatchTestsManagement />
<DiscountsManagement />
<GiftCardsManagement />
<BusinessSettings />
</div>
<!-- Modals -->