diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index a68e6f6..7fb5e94 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -4,6 +4,7 @@ import { toast } from 'svelte-sonner'; import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; + import type { Booking } from '$lib/types/booking'; interface Props { open: boolean; @@ -12,38 +13,6 @@ let { open = $bindable(), bookingId }: Props = $props(); - type Booking = { - id: string; - start_time: string; - status: string; - notes?: string; - created_at: string; - updated_at: string; - services: Array<{ - service_name?: string; - service_description?: string; - price?: number; - duration_minutes?: number; - }>; - payments: Array<{ - id: string; - payment_type: string; - payment_method: string; - status: string; - amount: number; - created_at: string; - invoice_number?: number; - is_vat_applicable: boolean; - vat_amount?: number; - net_amount?: number; - vat_rate?: number; - }>; - total_amount: number; - amount_paid: number; - amount_due: number; - duration_minutes: number; - }; - let selectedBooking = $state(null); let loading = $state(false); @@ -66,7 +35,7 @@ if (response.ok) { const data = await response.json(); - selectedBooking = data; + selectedBooking = data as Booking; } else { const text = await response.text(); toast.error('Failed to load booking: ' + text); @@ -169,6 +138,48 @@ + + {#if selectedBooking.deposit_required} +
+

+ Deposit Information +

+
+
+
Deposit Amount
+
+ £{selectedBooking.deposit_amount?.toFixed(2) || '0.00'} +
+
+
+
Deposit Status
+
+ + {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} + +
+
+ {#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid} +
+
Deadline
+
+ {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })} +
+
+ {/if} +
+
+ {/if} + {#if selectedBooking.services && selectedBooking.services.length > 0}
@@ -281,11 +292,14 @@ {#if selectedBooking} - {/if} diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index 1fcee13..9a7b1f1 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -5,6 +5,7 @@ import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte'; + import type { Booking } from '$lib/types/booking'; interface Props { open: boolean; @@ -13,75 +14,6 @@ let { open = $bindable(), bookingId }: Props = $props(); - type Booking = { - id: string; - start_time: string; - status: - | 'pending' - | 'confirmed' - | 'in_progress' - | 'completed' - | 'client_cancelled' - | 'we_cancelled' - | 're-schedule' - | 'no_show'; - notes?: string; - created_at: string; - updated_at: string; - created_by?: string; - - user?: { - id: string; - first_name: string; - last_name: string; - full_name: string; - email?: string; - phone?: string; - profile_pic_url?: string; - date_of_birth?: string; - account_role: string; - loyalty_stamps?: number; - referral_code?: string; - referral_code_uses?: number; - created_at: string; - notes?: string; - }; - - services: Array<{ - booking_id: string; - service_id: string; - override_price?: number; - override_duration_minutes?: number; - service_name?: string; - service_description?: string; - price?: number; - duration_minutes?: number; - }>; - - payments: Array<{ - id: string; - booking_id: string; - payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial'; - payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount'; - vendor_code?: string; - invoice_number?: number; - status: 'pending' | 'completed' | 'failed' | 'refunded'; - amount: number; - is_vat_applicable: boolean; - vat_rate?: number; - vat_amount?: number; - net_amount?: number; - created_at: string; - updated_at: string; - created_by?: string; - }>; - - total_amount: number; - amount_paid: number; - amount_due: number; - duration_minutes: number; - }; - let selectedBooking = $state(null); let showApprovalModal = $state(false); @@ -111,6 +43,17 @@ start_time: data.start_time, status: data.status, notes: data.notes, + created_at: data.created_at, + updated_at: data.updated_at, + created_by: data.created_by, + + // Deposit fields + deposit_required: data.deposit_required ?? false, + deposit_amount: data.deposit_amount, + deposit_paid: data.deposit_paid ?? false, + deposit_deadline: data.deposit_deadline, + + // User user: data.user ? { id: data.user.id, @@ -129,14 +72,20 @@ notes: data.user.notes } : undefined, + + // Services services: (data.services || []).map((s) => ({ booking_id: s.booking_id, service_id: s.service_id, service_name: s.service_name, service_description: s.service_description, price: s.price, - duration_minutes: s.duration_minutes + duration_minutes: s.duration_minutes, + override_price: s.override_price, + override_duration_minutes: s.override_duration_minutes })), + + // Payments payments: (data.payments || []).map((p) => ({ id: p.id, booking_id: p.booking_id, @@ -154,13 +103,12 @@ updated_at: p.updated_at, created_by: p.created_by })), + + // Financials total_amount: data.total_amount || 0, amount_paid: data.amount_paid || 0, amount_due: data.amount_due || 0, - duration_minutes: data.duration_minutes || 0, - created_at: data.created_at, - updated_at: data.updated_at, - created_by: data.created_by + duration_minutes: data.duration_minutes || 0 }; } else { const text = await response.text(); @@ -281,6 +229,48 @@ {/if}
+ + {#if selectedBooking.deposit_required} +
+

+ Deposit Information +

+
+
+
Deposit Amount
+
+ £{selectedBooking.deposit_amount?.toFixed(2) || '0.00'} +
+
+
+
Deposit Status
+
+ + {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} + +
+
+ {#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid} +
+
Deadline
+
+ {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })} +
+
+ {/if} +
+
+ {/if} + {#if selectedBooking.services && selectedBooking.services.length > 0}
diff --git a/frontend/src/lib/components/admin/BookingsCard.svelte b/frontend/src/lib/components/admin/BookingsCard.svelte index 63e88ae..de08762 100644 --- a/frontend/src/lib/components/admin/BookingsCard.svelte +++ b/frontend/src/lib/components/admin/BookingsCard.svelte @@ -8,64 +8,11 @@ import * as Card from '$lib/components/ui/card'; import { Input } from '$lib/components/ui/input'; import { Skeleton } from '$lib/components/ui/skeleton'; + import type { Booking } from '$lib/types/booking'; // Props let { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $props(); - // Types - type Booking = { - id: string; - start_time: string; - status: - | 'pending' - | 'confirmed' - | 'in_progress' - | 'completed' - | 'client_cancelled' - | 'we_cancelled' - | 're-schedule' - | 'no_show'; - notes?: string; - created_at: string; - updated_at: string; - created_by?: string; - user?: { - id: string; - full_name: string; - }; - services: Array<{ - booking_id: string; - service_id: string; - override_price?: number; - override_duration_minutes?: number; - service_name?: string; - service_description?: string; - price?: number; - duration_minutes?: number; - }>; - payments: Array<{ - id: string; - booking_id: string; - payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial'; - payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount'; - vendor_code?: string; - invoice_number?: number; - status: 'pending' | 'completed' | 'failed' | 'refunded'; - amount: number; - is_vat_applicable: boolean; - vat_rate?: number; - vat_amount?: number; - net_amount?: number; - created_at: string; - updated_at: string; - created_by?: string; - }>; - total_amount: number; - amount_paid: number; - amount_due: number; - duration_minutes: number; - }; - // State let bookings = $state([]); let totalBookings = $state(0); @@ -99,7 +46,7 @@ }); if (response.ok) { const data = await response.json(); - + if (data.bookings && data.bookings.length === 0) { bookings = []; totalBookings = 0; @@ -110,28 +57,9 @@ return; } - bookings = data.bookings.map((b: any) => ({ - id: b.id, - start_time: b.start_time, - status: b.status, - notes: b.notes, - created_at: b.created_at, - updated_at: b.updated_at, - created_by: b.created_by, - user: b.user - ? { - id: b.user.id, - full_name: b.user.full_name - } - : undefined, - services: b.services || [], - total_amount: b.total_amount || 0, - amount_paid: b.amount_paid || 0, - amount_due: b.amount_due || 0, - duration_minutes: b.duration_minutes || 0 - })); + bookings = data.bookings as Booking[]; totalBookings = data.total || 0; - totalPages = data.totalPages || 1; + totalPages = data.total_pages || 1; currentPage = data.page || 1; } else { const text = await response.text(); @@ -222,7 +150,7 @@ // Format services list function formatServices(services: Booking['services']): string { - const serviceNames = services.map((s) => s.service_name || 'Unknown Service'); + const serviceNames = services?.map((s) => s.service_name || 'Unknown Service') || []; if (serviceNames.length === 0) return 'No services'; if (serviceNames.length === 1) return serviceNames[0]; if (serviceNames.length === 2) return serviceNames.join(' and '); @@ -232,7 +160,7 @@ // Get status badge classes function getStatusClasses(status: Booking['status']): string { const baseClasses = 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium'; - const statusMap = { + const statusMap: Record = { confirmed: 'bg-emerald-100 text-emerald-800', pending: 'bg-amber-100 text-amber-800', in_progress: 'bg-blue-100 text-blue-800', @@ -240,13 +168,14 @@ client_cancelled: 'bg-red-100 text-red-800', we_cancelled: 'bg-rose-100 text-rose-800', 're-schedule': 'bg-purple-100 text-purple-800', - no_show: 'bg-gray-100 text-gray-800' + no_show: 'bg-gray-100 text-gray-800', + no_deposit: 'bg-orange-100 text-orange-800' }; return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`; } function getStatusDotClasses(status: Booking['status']): string { - const statusMap = { + const statusMap: Record = { confirmed: 'bg-emerald-600', pending: 'bg-amber-600', in_progress: 'bg-blue-600', @@ -254,7 +183,8 @@ client_cancelled: 'bg-red-600', we_cancelled: 'bg-rose-600', 're-schedule': 'bg-purple-600', - no_show: 'bg-gray-600' + no_show: 'bg-gray-600', + no_deposit: 'bg-orange-600' }; return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`; } diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index d1e75dd..5962813 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -651,17 +651,99 @@ async function submitBooking() { isSubmitting = true; try { - console.log('Submitting booking:', { - services: selectedServices, - date: selectedDate, - time: selectedTime, - customer: authStore.isAuthenticated ? authStore.currentUser : customerInfo + // Build the start_time in ISO format + if (!selectedDate || !selectedTime) { + toast.error('Please select a date and time'); + return; + } + + // Convert CalendarDate to JavaScript Date, then to ISO string + const [hours, minutes] = selectedTime.split(':').map(Number); + const bookingDate = selectedDate.toDate(getLocalTimeZone()); + bookingDate.setHours(hours, minutes, 0, 0); + const startTimeISO = bookingDate.toISOString(); + + // Extract service IDs + const serviceIds = selectedServices.map((s) => s.id); + + // Build request body + const requestBody = { + service_ids: serviceIds, + start_time: startTimeISO, + notes: customerInfo.specialRequests || null + }; + + console.log('Submitting booking:', requestBody); + + const response = await fetch('/api/bookings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify(requestBody) }); - toast.success('Booking submitted successfully!'); + if (response.ok) { + const booking = await response.json(); + console.log('Booking created:', booking); + + // Show success message with booking details + const bookingDateStr = new Date(booking.start_time).toLocaleDateString('en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long' + }); + const bookingTimeStr = new Date(booking.start_time).toLocaleTimeString('en-GB', { + hour: 'numeric', + minute: '2-digit' + }); + + toast.success(`Booking confirmed for ${bookingDateStr} at ${bookingTimeStr}!`); + + // Reset form and redirect to account page + selectedServices = []; + selectedDate = undefined; + selectedTime = null; + currentStep = 1; + + // Navigate to account page to see bookings + window.location.href = '/account'; + } else { + // Handle error response + const errorText = await response.text(); + let errorMessage = 'Failed to submit booking. Please try again.'; + + // Try to parse error message from backend + try { + const errorData = JSON.parse(errorText); + if (errorData.error) { + errorMessage = errorData.error; + } else if (typeof errorData === 'string') { + errorMessage = errorData; + } + } catch { + // Use default message if parsing fails + } + + // Handle specific error cases + if (response.status === 409) { + toast.error('This time slot is no longer available. Please choose a different time.'); + } else if (errorMessage.includes('patch test') || errorMessage.includes('Patch test')) { + toast.error(errorMessage + ' Please complete a patch test first.'); + } else if (errorMessage.includes('48 hours') || errorMessage.includes('48h')) { + toast.error(errorMessage); + } else if (response.status === 400) { + toast.error(errorMessage); + } else { + toast.error('Failed to submit booking: ' + errorMessage); + } + + console.error('Booking submission failed:', response.status, errorText); + } } catch (error) { - console.error('Booking submission failed:', error); - toast.error('Failed to submit booking. Please try again.'); + console.error('Booking submission error:', error); + toast.error('Network error. Please check your connection and try again.'); } finally { isSubmitting = false; } diff --git a/frontend/src/lib/types/booking.ts b/frontend/src/lib/types/booking.ts index d9b1e74..23b9944 100644 --- a/frontend/src/lib/types/booking.ts +++ b/frontend/src/lib/types/booking.ts @@ -39,3 +39,94 @@ export interface AvailableHoursDay { slots: Array<{ startTime: string; endTime: string }>; source: string; } + +export type BookingStatus = + | 'pending' + | 'confirmed' + | 'in_progress' + | 'completed' + | 'client_cancelled' + | 'we_cancelled' + | 're-schedule' + | 'no_show' + | 'no_deposit'; + +export interface BookingService { + booking_id: string; + service_id: string; + override_price?: number; + override_duration_minutes?: number; + service_name?: string; + service_description?: string; + price?: number; + duration_minutes?: number; +} + +export interface BookingUser { + id: string; + first_name: string; + last_name: string; + full_name: string; + email?: string; + phone?: string; + profile_pic_url?: string; + date_of_birth?: string; + account_role: string; + loyalty_stamps?: number; + referral_code?: string; + referral_code_uses?: number; + created_at: string; + notes?: string; +} + +export interface Payment { + id: string; + booking_id: string; + payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial'; + payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount'; + vendor_code?: string; + invoice_number?: number; + status: 'pending' | 'completed' | 'failed' | 'refunded'; + amount: number; + is_vat_applicable: boolean; + vat_rate?: number; + vat_amount?: number; + net_amount?: number; + created_at: string; + updated_at: string; + created_by?: string; +} + +export interface Booking { + id: string; + start_time: string; + status: BookingStatus; + notes?: string; + created_at: string; + updated_at: string; + created_by?: string; + + // Deposit fields + deposit_required: boolean; + deposit_amount?: number; + deposit_paid: boolean; + deposit_deadline?: string; + + // Joined fields + user?: BookingUser; + services?: BookingService[]; + payments?: Payment[]; + total_amount: number; + amount_paid: number; + amount_due: number; + duration_minutes: number; +} + +export interface BookingListResponse { + bookings: Booking[]; + page: number; + per_page: number; + total: number; + total_pages: number; +} +