Files
Crussell/frontend/src/routes/admin/+page.svelte
T

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}