Files
Crussell/frontend/src/routes/admin/+page.svelte
T
popertots 6470a3f6c9 feat: add scheduled change UI and conflict display to weekly schedule
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.
2026-08-22 00:34:49 +01:00

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}