Compare commits
5
Commits
b1b6607ca1
...
5f1f6c4b44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f1f6c4b44 | ||
|
|
5616357269 | ||
|
|
b7fa22b018 | ||
|
|
56eda0ead5 | ||
|
|
42c3b65a27 |
@@ -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);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user