From 42c3b65a2730e0ebefe09169ecc98776500030c1 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 5 Jul 2026 22:25:49 +0100 Subject: [PATCH] feat: add collapsible sections and lifted data fetching to admin page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add independently toggleable collapsible sections for scheduling, customers, services, promotions, settings. Lift services and default-hours fetching to page level to avoid duplicate API calls. Add responsive breakpoint detection — sections default expanded on desktop, collapsed on mobile. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/routes/admin/+page.svelte | 443 ++++++++++++++----------- 1 file changed, 258 insertions(+), 185 deletions(-) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 92a70f0..f440214 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -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(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>([]); + let services = $state>([]); + + // 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 @@ {#if pageState === 'loading'} -
@@ -106,189 +181,76 @@
- +
-
- - -
- -
- -
-
- - -
- -
-
- - -
-
- - -
-
- {#each Array(3) as _, i (i)} - - {/each} -
-
- - -
-
- - -
-
- - -
-
- {#each Array(3) as _, i (i)} - - {/each} -
-
-
- - -
-
-
- - -
- -
-
- {#each Array(3) as _, i (i)} - - {/each} -
-
- - - - -
-
- - -
-
+
- -
-
- - - - - - - - - - - {#each Array(7) as _, i (i)} - - - - - - - {/each} - -
+
+
- +
-
- - -
-
- -
- -
- - -
-
+
- + + +
+ +
+
+ + +
+
+ + +
+
+
+
- +
- + + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+
{:else if pageState === 'authorized'} @@ -300,20 +262,131 @@
+ -
- - + + +
+ + {#if sectionState.customers === 'expanded'} +
+
+ + +
+
+ {/if} +
+ + +
+ + {#if sectionState.services === 'expanded'} +
+ + + { + 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); + } + }} /> +
+ {/if} +
+ + +
+ + {#if sectionState.scheduling === 'expanded'} +
+ + + +
+ {/if} +
+ + +
+ + {#if sectionState.promotions === 'expanded'} +
+ + +
+ {/if} +
+ + +
+ + {#if sectionState.settings === 'expanded'} +
+ +
+ {/if}
- - - - - - - - -