455 lines
14 KiB
Svelte
455 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) {
|
|
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;
|
|
}
|
|
|
|
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 />
|
|
<WeeklySchedule {defaultHours} />
|
|
</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}
|