feat(bookings): improve admin booking wizard and user dashboard

Backend:
- Enriched GetAllUserBookings response with calculated total_amount,
  amount_paid, and duration_minutes.
- Refactored GetBookingHandler to return a flat booking object matching
  frontend expectations.
- Added account_role to admin user list response and sorted users by
  booking activity.
- Corrected function name oo to AdminCreateBookingForUserHandler.

Frontend:
- Rebuilt BookingCreateModal into a 4-step wizard supporting guest
  bookings, service overrides, and real-time availability checks.
- Fixed account dashboard logic to correctly identify upcoming vs past
  bookings and sort unpaid items to the top.
- Extracted booking flow into a shared BookingFlow component.
- Redirected admin users from home page to /today.
This commit is contained in:
2026-02-12 22:15:10 +00:00
parent 2ace6d4d87
commit 50746595e7
34 changed files with 7688 additions and 5413 deletions
+6 -1
View File
@@ -37,7 +37,12 @@
<p class="mb-8 text-lg text-gray-600">
Professional beauty treatments in a calm and friendly environment.
</p>
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button>
{#if authStore.currentUser?.role === 'admin'}
<Button href="/today" class="px-6 py-3 text-lg">View your day</Button>
{:else}
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button>
{/if}
{/if}
</section>
+100 -69
View File
@@ -4,6 +4,7 @@
import { SvelteDate } from 'svelte/reactivity';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -110,7 +111,9 @@
loadingUpcoming = true;
try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const response = await fetch(`/api/bookings?start_date=${today}&per_page=3&page=1`, {
// Fetch more items (e.g. 10) to ensure we find upcoming ones even if the first few are past
const response = await fetch(`/api/bookings?start_date=${today}&per_page=10&page=1`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -125,7 +128,18 @@
}
const data = await response.json();
upcomingBookings = data.bookings || [];
const now = new Date();
// Filter: Calculate end time (Start + Duration) and check if it's in the future
const activeOrFutureBookings = (data.bookings || []).filter((b: any) => {
const startTime = new Date(b.start_time);
// Add duration (in ms)
const endTime = new Date(startTime.getTime() + (b.duration_minutes || 0) * 60000);
return endTime > now;
});
// Take only the top 3
upcomingBookings = activeOrFutureBookings.slice(0, 3);
} catch (err) {
console.error('Error fetching upcoming bookings:', err);
toast.error('Network error loading upcoming bookings');
@@ -140,7 +154,7 @@
loadingPast = true;
try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const today = new Date().toISOString().split('T')[0];
const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, {
method: 'GET',
headers: {
@@ -156,7 +170,35 @@
}
const data = await response.json();
pastBookings = data.bookings || [];
let bookings = data.bookings || [];
// FIX: Manually calculate amount_due for the list
// The list API often returns 0 for amount_due/amount_paid,
// so we derive it from total_amount.
bookings = bookings.map((b: any) => {
const total = b.total_amount || 0;
const paid = b.amount_paid || 0;
return {
...b,
amount_due: total - paid // Force calculate the balance
};
});
// SORT LOGIC: Unpaid first, then by most recent
bookings.sort((a: any, b: any) => {
const aUnpaid = (a.amount_due || 0) > 0;
const bUnpaid = (b.amount_due || 0) > 0;
// If A is unpaid and B is not, A comes first
if (aUnpaid && !bUnpaid) return -1;
// If B is unpaid and A is not, B comes first
if (!aUnpaid && bUnpaid) return 1;
// If both have same payment status, sort by Date DESC (newest first)
return new Date(b.start_time).getTime() - new Date(a.start_time).getTime();
});
pastBookings = bookings;
pastPage = data.page || page;
pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10));
} catch (err) {
@@ -233,6 +275,16 @@
}
}
// =============== Booking modal ==============
// =============== Modal State ===============
let showBookingModal = $state(false);
let selectedBookingId = $state<string | null>(null);
function openBookingModal(id: string) {
selectedBookingId = id;
showBookingModal = true;
}
// =============== Account Deletion ===============
let showDeleteAlert = $state(false);
let deleteConfirmText = $state('');
@@ -504,14 +556,13 @@
{#each Array(3) as _, i (i)}
<Skeleton class="h-16 w-full" />
{/each}
{:else if upcomingBookings.length === 0}
<div class="py-4 text-center text-gray-500">No upcoming bookings</div>
{:else}
{#each upcomingBookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">{formatDateTime(b.start_time)}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<!-- Show Status Chip for Upcoming -->
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
'confirmed'
@@ -520,38 +571,27 @@
? 'bg-amber-100 text-amber-800'
: b.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: b.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'}"
: 'bg-gray-100 text-gray-800'}"
>
<span
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
? 'bg-emerald-600'
: b.status === 'pending'
? 'bg-amber-600'
: b.status === 'in_progress'
? 'bg-blue-600'
: b.status === 'completed'
? 'bg-green-600'
: 'bg-gray-600'}"
></span>
{b.status}
</span>
<span>
- {(() => {
const services = (b.services || []).map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 0) return 'No services';
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
<!-- Services: Only show if data exists -->
{#if b.services && b.services.length > 0}
<span>
- {(() => {
const services = b.services.map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
{/if}
</div>
</div>
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button
>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
@@ -578,45 +618,31 @@
<div class="flex-1">
<div class="font-medium">{formatDateTime(b.start_time)}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {b.status ===
'confirmed'
? 'bg-emerald-100 text-emerald-800'
: b.status === 'pending'
? 'bg-amber-100 text-amber-800'
: b.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: b.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'}"
>
<!-- Unpaid Chip: Matches the 'Confirmed' chip style but uses Red for urgency -->
{#if (b.amount_due || 0) > 0}
<span
class="mr-1 h-1.5 w-1.5 rounded-full {b.status === 'confirmed'
? 'bg-emerald-600'
: b.status === 'pending'
? 'bg-amber-600'
: b.status === 'in_progress'
? 'bg-blue-600'
: b.status === 'completed'
? 'bg-green-600'
: 'bg-gray-600'}"
></span>
{b.status}
</span>
<span>
- {(() => {
const services = (b.services || []).map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 0) return 'No services';
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
>
Unpaid
</span>
{/if}
<!-- Services: Hidden if empty -->
{#if b.services && b.services.length > 0}
<span>
- {(() => {
const services = b.services.map(
(s) => s.service_name || 'Unknown Service'
);
if (services.length === 1) return services[0];
if (services.length === 2) return services.join(' and ');
return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`;
})()}
</span>
{/if}
</div>
</div>
<Button variant="outline" onclick={() => goto(`/bookings/${b.id}`)}>View</Button>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/if}
@@ -991,6 +1017,11 @@
</AlertDialog.Root>
{/if}
<!-- User Booking Modal -->
{#if showBookingModal && selectedBookingId}
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId} />
{/if}
<style>
/* Mobile Tab Menu Styles */
.mobile-tab-menu {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
async function proxyRequest(request: Request, path: string) {
const incomingUrl = new URL(request.url);
const queryString = incomingUrl.search;
const url = `${BACKEND_URL}/api/${path}${queryString}`;
try {
const headers = new Headers(request.headers);
headers.delete('host');
const backendRes = await fetch(url, {
method: request.method,
headers,
body: ['GET', 'HEAD'].includes(request.method)
? undefined
: await request.text()
});
// Forward everything transparently
const resHeaders = new Headers(backendRes.headers);
return new Response(backendRes.body, {
status: backendRes.status,
headers: resHeaders
});
} catch (err) {
console.error('Proxy error:', err);
return error(502, 'Backend unreachable');
}
}
export const GET: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const POST: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const PUT: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const DELETE: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
export const PATCH: RequestHandler = ({ request, params }) => proxyRequest(request, params.path);
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
## Goal
Decompose `+page.svelte` into focused, testable components with clear data flow and minimal shared state.
---
## Highlevel structure
```
/routes/book/+page.svelte
/lib/components/booking/
BookingFlow.svelte
StepIndicator.svelte
ServiceSelector.svelte
ServiceCard.svelte
DatePicker.svelte
TimeSlotPicker.svelte
CustomerDetailsForm.svelte
BookingSummary.svelte
BookingActions.svelte
/lib/stores/booking.ts
/lib/types/booking.ts
```
---
## State ownership (important)
**Single source of truth:** `BookingFlow.svelte`
* currentStep
* selectedServices
* selectedDate
* selectedTime
* customerInfo
* pricing / totals
Everything else receives props + dispatches events. No hidden global coupling. 🧠
---
## Components
### 1. `BookingFlow.svelte`
**Role:** Orchestrator
* Holds booking state
* Validates transitions between steps
* Calls API + handles toasts
**Props:** none
**Emits:** none
This is the only component allowed to know *everything*.
---
### 2. `StepIndicator.svelte`
**Role:** Visual progress
**Props**
```ts
currentStep: number
totalSteps: number
```
Pure UI. Zero logic.
---
### 3. `ServiceSelector.svelte`
**Role:** Choose services
**Props**
```ts
services: Service[]
selected: Service[]
```
**Emits**
```ts
select(service)
deselect(service)
```
Internally renders multiple `ServiceCard`s.
---
### 4. `ServiceCard.svelte`
**Role:** One service tile
**Props**
```ts
service: Service
selected: boolean
```
No awareness of booking steps or pricing totals.
---
### 5. `DatePicker.svelte`
**Role:** Calendar selection
Wraps your existing `Calendar` usage.
**Props**
```ts
date: CalendarDate | undefined
```
**Emits**
```ts
change(date)
```
---
### 6. `TimeSlotPicker.svelte`
**Role:** Pick available time
**Props**
```ts
date: CalendarDate
selectedTime: string | null
availability: TimeSlot[]
```
**Emits**
```ts
select(time)
```
---
### 7. `CustomerDetailsForm.svelte`
**Role:** Collect user info
**Props**
```ts
value: CustomerInfo
```
**Emits**
```ts
update(value)
```
No submit button here. Forms shouldnt decide flow control.
---
### 8. `BookingSummary.svelte`
**Role:** Readonly confirmation
**Props**
```ts
services: Service[]
date: CalendarDate
time: string
customer: CustomerInfo
total: number
```
Zero mutation. Snapshot only.
---
### 9. `BookingActions.svelte`
**Role:** Navigation + submit
**Props**
```ts
canBack: boolean
canNext: boolean
isSubmitting: boolean
```
**Emits**
```ts
back()
next()
submit()
```
---
## Shared types
Move all interfaces out of `+page.svelte`:
```ts
// lib/types/booking.ts
export interface Service { ... }
export interface CustomerInfo { ... }
export interface TimeSlot { ... }
```
---
## Optional store (recommended)
```ts
// lib/stores/booking.ts
export const bookingStore = $state({ ... })
```
Use only if the flow must persist across routes or reloads. Otherwise keep it local.
---
## Result
* Smaller files
* Predictable data flow
* Each component explainable in one sentence
* Easier testing and future changes
Entropy reduced. ✂️🧩
---
## Step 1 — Extract the brain (no store yet)
We start by **wrapping the existing logic**, not rewriting it.
### 1. Create `BookingFlow.svelte`
Move *all* bookingrelated state and logic out of `+page.svelte` into this file.
```svelte
<script lang="ts">
import StepIndicator from './StepIndicator.svelte';
import ServiceSelector from './ServiceSelector.svelte';
import DatePicker from './DatePicker.svelte';
import TimeSlotPicker from './TimeSlotPicker.svelte';
import CustomerDetailsForm from './CustomerDetailsForm.svelte';
import BookingSummary from './BookingSummary.svelte';
import BookingActions from './BookingActions.svelte';
import type { Service, CustomerInfo, TimeSlot } from '$lib/types/booking';
// --- State (copied verbatim from +page.svelte) ---
let currentStep = 1;
let selectedServices: Service[] = [];
let selectedDate;
let selectedTime: string | null = null;
let customerInfo: CustomerInfo = { /* unchanged */ };
// pricing, derived values, API calls stay here
</script>
<StepIndicator {currentStep} totalSteps={4} />
{#if currentStep === 1}
<ServiceSelector
services={services}
selected={selectedServices}
on:select={(e) => selectedServices.push(e.detail)}
on:deselect={(e) => selectedServices = selectedServices.filter(s => s.id !== e.detail.id)}
/>
{:else if currentStep === 2}
<DatePicker bind:date={selectedDate} />
<TimeSlotPicker
date={selectedDate}
availability={availability}
bind:selectedTime
/>
{:else if currentStep === 3}
<CustomerDetailsForm bind:value={customerInfo} />
{:else if currentStep === 4}
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={customerInfo}
total={total}
/>
{/if}
<BookingActions
canBack={currentStep > 1}
canNext={currentStep < 4}
on:back={() => currentStep--}
on:next={() => currentStep++}
on:submit={submitBooking}
/>
```
Nothing clever yet. This is a **containerization step**, not a refactor.
---
### 2. Reduce `+page.svelte` to glue
```svelte
<script lang="ts">
import BookingFlow from '$lib/components/booking/BookingFlow.svelte';
</script>
<BookingFlow />
```
At this point:
* Behaviour is identical
* No store introduced
* You have a single, explicit "brain"
---
## Step 2 — Extract `ServiceCard` (low risk, high reward)
This is the safest cut: **pure UI, minimal state, zero flow control**.
---
### 2.1 Identify the slice
In `ServiceSelector`, find the repeated markup that:
* Displays service name / price / duration
* Highlights selected state
* Handles click / toggle
If it *renders one service*, it becomes a card.
---
### 2.2 Create `ServiceCard.svelte`
```svelte
<script lang="ts">
import type { Service } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
export let service: Service;
export let selected = false;
const dispatch = createEventDispatcher();
function toggle() {
dispatch(selected ? 'deselect' : 'select', service);
}
</script>
<button
class:selected
on:click={toggle}
>
<h3>{service.name}</h3>
<p>{service.duration} min</p>
<p>£{service.price}</p>
</button>
<style>
button { /* existing styles */ }
.selected { /* existing selected styles */ }
</style>
```
No booking logic. No totals. No step awareness.
---
### 2.3 Simplify `ServiceSelector.svelte`
```svelte
<script lang="ts">
import ServiceCard from './ServiceCard.svelte';
import type { Service } from '$lib/types/booking';
export let services: Service[] = [];
export let selected: Service[] = [];
</script>
<div class="grid">
{#each services as service (service.id)}
<ServiceCard
{service}
selected={selected.some(s => s.id === service.id)}
on:select
on:deselect
/>
{/each}
</div>
```
Selection state still lives *above*. This is critical.
---
## Validation checkpoint
At this point:
* `ServiceCard` is dumb
* `ServiceSelector` coordinates cards
* `BookingFlow` owns truth
If this feels boring, good. Boring code is stable code.
---
## Step 3 — Extract `CustomerDetailsForm` (controlled input boundary)
This step removes a classic source of entropy: **forms that secretly control flow**.
The rule here is strict:
> Forms collect data. Parents decide what to do with it.
---
### 3.1 Identify the form logic
In `BookingFlow`, locate:
* Name / email / phone inputs
* Validation messages
* `on:input` handlers
Anything that mutates `customerInfo` belongs in the form *except* submission.
---
### 3.2 Create `CustomerDetailsForm.svelte`
```svelte
<script lang="ts">
import type { CustomerInfo } from '$lib/types/booking';
import { createEventDispatcher } from 'svelte';
export let value: CustomerInfo;
const dispatch = createEventDispatcher();
function update<K extends keyof CustomerInfo>(key: K, val: CustomerInfo[K]) {
dispatch('update', { ...value, [key]: val });
}
</script>
<div class="space-y-4">
<input
type="text"
placeholder="Name"
value={value.name}
on:input={(e) => update('name', e.currentTarget.value)}
/>
<input
type="email"
placeholder="Email"
value={value.email}
on:input={(e) => update('email', e.currentTarget.value)}
/>
<input
type="tel"
placeholder="Phone"
value={value.phone}
on:input={(e) => update('phone', e.currentTarget.value)}
/>
</div>
```
No submit button. No step logic. No API calls.
---
### 3.3 Wire it back into `BookingFlow`
Replace inline inputs with:
```svelte
<CustomerDetailsForm
value={customerInfo}
on:update={(e) => customerInfo = e.detail}
/>
```
Validation still lives in `BookingFlow`:
* Can we go to the next step?
* Is submit enabled?
---
## Validation checkpoint
You should now observe:
* The form is reusable
* BookingFlow got smaller
* Step logic is easier to read
If you *cant* explain where a rule lives in one sentence, its in the wrong place.
---
## Step 4 — Split `DatePicker` and `TimeSlotPicker` (explicit dependency)
This is the first step where **ordering matters**. Time is meaningless without a date. We make that dependency obvious and onedirectional.
Rule of the step:
> Date flows down. Time flows up.
---
### 4.1 Extract `DatePicker.svelte`
This component selects *only* a date. No availability logic. No time awareness.
```svelte
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CalendarDate } from '@internationalized/date';
export let date: CalendarDate | undefined;
const dispatch = createEventDispatcher();
</script>
<Calendar
value={date}
on:change={(e) => dispatch('change', e.detail)}
/>
```
It emits intent. Thats it.
---
### 4.2 Extract `TimeSlotPicker.svelte`
Time slots depend on *inputs*, never globals.
```svelte
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { TimeSlot } from '$lib/types/booking';
export let date; // required
export let availability: TimeSlot[] = [];
export let selectedTime: string | null = null;
const dispatch = createEventDispatcher();
</script>
{#if !date}
<p class="text-muted">Select a date first</p>
{:else}
<div class="grid">
{#each availability as slot (slot.time)}
<button
class:selected={slot.time === selectedTime}
disabled={!slot.available}
on:click={() => dispatch('select', slot.time)}
>
{slot.time}
</button>
{/each}
</div>
{/if}
```
No fetching. No step logic. Just rendering.
---
### 4.3 Wire in `BookingFlow`
Here is the **complete and correct wiring**, including guards and reset logic:
```svelte
<DatePicker
date={selectedDate}
on:change={(e) => {
const newDate = e.detail;
selectedDate = newDate;
// changing date invalidates time
selectedTime = null;
// fetch / recompute availability here
loadAvailability(newDate);
}}
/>
<TimeSlotPicker
date={selectedDate}
availability={availability}
selectedTime={selectedTime}
on:select={(e) => {
selectedTime = e.detail;
}}
/>
```
All temporal logic lives here. Children stay honest.
+3 -4
View File
@@ -9,9 +9,8 @@
import CurrentAppointment from '$lib/components/today/CurrentAppointment.svelte';
import TodayCalendar from '$lib/components/today/TodayCalendar.svelte';
import PendingApprovals from '$lib/components/today/PendingApprovals.svelte';
// import TodayRevenue from '$lib/components/today/TodayRevenue.svelte';
// import LoyaltyStats from '$lib/components/today/LoyaltyStats.svelte';
import CallInBooking from '$lib/components/admin/CallInBooking.svelte';
import WalkInBooking from '$lib/components/admin/WalkInBooking.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte';
@@ -111,12 +110,12 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column: Create a booking for a call in or messaging user (2/3 width on large screens) -->
<div class="lg:col-span-2">
<CallInBooking />
<WalkInBooking />
</div>
<!-- Right Column: Similar to the above but immediately block out my next availible working space while I talk over the walk-in users needs and chat etc (1/3 width on large screens) -->
<div class="space-y-6">
<!-- <WalkInBooking /> -->
<CallInBooking />
</div>
</div>