Convert WeeklySchedule to schedule staged changes with an effective date picker and conflict detection UI. Show pending scheduled changes in BusinessHours component. Update admin page to pass through props.
456 lines
14 KiB
Svelte
456 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import { apiFetch } from '$lib/utils/api';
|
|
import { browser } from '$app/environment';
|
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
|
|
|
// Component imports
|
|
import ImageUpload from '$lib/components/admin/ImageUpload.svelte';
|
|
import UsersCard from '$lib/components/admin/UsersCard.svelte';
|
|
import BookingsCard from '$lib/components/admin/BookingsCard.svelte';
|
|
import HolidayHours from '$lib/components/admin/HolidayHours.svelte';
|
|
import TimeBlockers from '$lib/components/admin/TimeBlockers.svelte';
|
|
import WeeklySchedule from '$lib/components/admin/WeeklySchedule.svelte';
|
|
import ServicesManagement from '$lib/components/admin/ServicesManagement.svelte';
|
|
import CustomServicesManagement from '$lib/components/admin/CustomServicesManagement.svelte';
|
|
import PatchTestsManagement from '$lib/components/admin/PatchTestsManagement.svelte';
|
|
import DiscountsManagement from '$lib/components/admin/DiscountsManagement.svelte';
|
|
import GiftCardsManagement from '$lib/components/admin/GiftCardsManagement.svelte';
|
|
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');
|
|
|
|
// Check permissions immediately and on auth changes
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
if (authStore.isLoading) {
|
|
pageState = 'loading';
|
|
return;
|
|
}
|
|
|
|
if (!authStore.isAuthenticated) {
|
|
pageState = 'unauthorized';
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
goto('/login', { replaceState: true });
|
|
return;
|
|
}
|
|
|
|
if (authStore.currentUser?.role !== 'admin') {
|
|
pageState = 'unauthorized';
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
goto('/', { replaceState: true });
|
|
return;
|
|
}
|
|
|
|
pageState = 'authorized';
|
|
});
|
|
|
|
// =============== Modal State ===============
|
|
let showUserModal = $state(false);
|
|
let showBookingModal = $state(false);
|
|
let selectedUserId = $state<string | null>(null);
|
|
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'
|
|
});
|
|
|
|
// =============== 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)');
|
|
|
|
function handleChange(e: MediaQueryListEvent) {
|
|
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([
|
|
apiFetch('/api/scheduling/default-hours'),
|
|
apiFetch('/api/admin/services', {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
]);
|
|
|
|
if (hoursRes.ok) {
|
|
const result = await hoursRes.json();
|
|
defaultHours = result.current || [];
|
|
}
|
|
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;
|
|
}
|
|
|
|
function openBookingModal(bookingId: string) {
|
|
selectedBookingId = bookingId;
|
|
showBookingModal = true;
|
|
}
|
|
|
|
function handleReschedule() {
|
|
rescheduleVersion++;
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<script>
|
|
(function () {
|
|
// Pre-hydration auth guard: reads localStorage directly because the Svelte
|
|
// authStore hasn't initialized yet at this point (async+$state). This runs
|
|
// synchronously in <svelte:head> before any rendering, preventing a flash
|
|
// of protected content. The authStore handles post-hydration auth.
|
|
try {
|
|
var token = localStorage.getItem('authToken');
|
|
if (!token) {
|
|
window.location.replace('/login');
|
|
return;
|
|
}
|
|
var payload = JSON.parse(atob(token.split('.')[1]));
|
|
if (payload.exp * 1000 <= Date.now()) {
|
|
window.location.replace('/login');
|
|
return;
|
|
}
|
|
if (payload.role !== 'admin') {
|
|
window.location.replace('/');
|
|
}
|
|
} catch (e) {
|
|
window.location.replace('/login');
|
|
}
|
|
})();
|
|
</script>
|
|
</svelte:head>
|
|
|
|
{#if pageState === '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">
|
|
<div class="space-y-2">
|
|
<Skeleton class="h-8 w-64" />
|
|
<Skeleton class="h-4 w-96" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 1. Portfolio Images -->
|
|
<div class="rounded-lg border p-6">
|
|
<div class="space-y-2">
|
|
<Skeleton class="h-6 w-40" />
|
|
<Skeleton class="h-4 w-56" />
|
|
</div>
|
|
<Skeleton class="mt-4 h-32 w-full rounded-lg" />
|
|
</div>
|
|
|
|
<!-- 2. Customers & Bookings -->
|
|
<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-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-5 w-5" />
|
|
</div>
|
|
<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'}
|
|
<div class="mx-auto max-w-6xl space-y-6 p-6">
|
|
<div class="mb-4 flex items-center justify-center text-center">
|
|
<div>
|
|
<h1 class="font-['Playfair_Display'] text-4xl font-bold">Admin Dashboard</h1>
|
|
<p class="text-gray-600">Manage portfolio images, working hours & user bookings</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 1. Portfolio Images -->
|
|
<ImageUpload />
|
|
|
|
<!-- 2. Customers & Bookings -->
|
|
<div class="rounded-lg border">
|
|
<button
|
|
type="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
|
|
type="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} />
|
|
<ServicesManagement
|
|
{services}
|
|
onRefresh={async () => {
|
|
if (!browser) return;
|
|
try {
|
|
const r = await apiFetch('/api/admin/services', {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
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
|
|
type="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} />
|
|
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
|
|
<WeeklySchedule {defaultHours} {openUserModal} {openBookingModal} {rescheduleVersion} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- 5. Promotions & Finance -->
|
|
<div class="rounded-lg border">
|
|
<button
|
|
type="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
|
|
type="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>
|
|
</div>
|
|
|
|
<!-- Modals -->
|
|
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
|
|
|
|
<BookingModal
|
|
bind:open={showBookingModal}
|
|
bookingId={selectedBookingId ?? ''}
|
|
onReschedule={handleReschedule}
|
|
/>
|
|
{/if}
|