Update frontend to match new tested backend

This commit is contained in:
2026-03-02 22:49:20 +00:00
parent 1e13ccda05
commit ec2dfb6934
5 changed files with 308 additions and 201 deletions
@@ -4,6 +4,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import type { Booking } from '$lib/types/booking';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -12,38 +13,6 @@
let { open = $bindable(), bookingId }: Props = $props(); 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<Booking | null>(null); let selectedBooking = $state<Booking | null>(null);
let loading = $state(false); let loading = $state(false);
@@ -66,7 +35,7 @@
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
selectedBooking = data; selectedBooking = data as Booking;
} else { } else {
const text = await response.text(); const text = await response.text();
toast.error('Failed to load booking: ' + text); toast.error('Failed to load booking: ' + text);
@@ -169,6 +138,48 @@
</div> </div>
</div> </div>
<!-- Deposit Info -->
{#if selectedBooking.deposit_required}
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-blue-800 uppercase">
Deposit Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-blue-600">Deposit Amount</div>
<div class="font-semibold text-blue-900">
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
</div>
</div>
<div>
<div class="text-xs text-blue-600">Deposit Status</div>
<div class="font-semibold">
<span
class="{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'} inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
</div>
</div>
{#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid}
<div class="md:col-span-2">
<div class="text-xs text-blue-600">Deadline</div>
<div class="font-semibold text-blue-900">
{new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Services --> <!-- Services -->
{#if selectedBooking.services && selectedBooking.services.length > 0} {#if selectedBooking.services && selectedBooking.services.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
@@ -281,11 +292,14 @@
<Modal.Footer class="flex items-center justify-end gap-2"> <Modal.Footer class="flex items-center justify-end gap-2">
{#if selectedBooking} {#if selectedBooking}
<Button variant="outline" onclick={() => { <Button
if (selectedBooking) { variant="outline"
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank'); onclick={() => {
} if (selectedBooking) {
}}> window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
}
}}
>
Add to Calendar Add to Calendar
</Button> </Button>
{/if} {/if}
@@ -5,6 +5,7 @@
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte'; import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import type { Booking } from '$lib/types/booking';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -13,75 +14,6 @@
let { open = $bindable(), bookingId }: Props = $props(); 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<Booking | null>(null); let selectedBooking = $state<Booking | null>(null);
let showApprovalModal = $state(false); let showApprovalModal = $state(false);
@@ -111,6 +43,17 @@
start_time: data.start_time, start_time: data.start_time,
status: data.status, status: data.status,
notes: data.notes, 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 user: data.user
? { ? {
id: data.user.id, id: data.user.id,
@@ -129,14 +72,20 @@
notes: data.user.notes notes: data.user.notes
} }
: undefined, : undefined,
// Services
services: (data.services || []).map((s) => ({ services: (data.services || []).map((s) => ({
booking_id: s.booking_id, booking_id: s.booking_id,
service_id: s.service_id, service_id: s.service_id,
service_name: s.service_name, service_name: s.service_name,
service_description: s.service_description, service_description: s.service_description,
price: s.price, 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) => ({ payments: (data.payments || []).map((p) => ({
id: p.id, id: p.id,
booking_id: p.booking_id, booking_id: p.booking_id,
@@ -154,13 +103,12 @@
updated_at: p.updated_at, updated_at: p.updated_at,
created_by: p.created_by created_by: p.created_by
})), })),
// Financials
total_amount: data.total_amount || 0, total_amount: data.total_amount || 0,
amount_paid: data.amount_paid || 0, amount_paid: data.amount_paid || 0,
amount_due: data.amount_due || 0, amount_due: data.amount_due || 0,
duration_minutes: data.duration_minutes || 0, duration_minutes: data.duration_minutes || 0
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by
}; };
} else { } else {
const text = await response.text(); const text = await response.text();
@@ -281,6 +229,48 @@
{/if} {/if}
</div> </div>
<!-- Deposit Info -->
{#if selectedBooking.deposit_required}
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-blue-800 uppercase">
Deposit Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-blue-600">Deposit Amount</div>
<div class="font-semibold text-blue-900">
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
</div>
</div>
<div>
<div class="text-xs text-blue-600">Deposit Status</div>
<div class="font-semibold">
<span
class="{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'} inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
</div>
</div>
{#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid}
<div class="md:col-span-2">
<div class="text-xs text-blue-600">Deadline</div>
<div class="font-semibold text-blue-900">
{new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Services --> <!-- Services -->
{#if selectedBooking.services && selectedBooking.services.length > 0} {#if selectedBooking.services && selectedBooking.services.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
@@ -8,64 +8,11 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import type { Booking } from '$lib/types/booking';
// Props // Props
let { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $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 // State
let bookings = $state<Booking[]>([]); let bookings = $state<Booking[]>([]);
let totalBookings = $state(0); let totalBookings = $state(0);
@@ -110,28 +57,9 @@
return; return;
} }
bookings = data.bookings.map((b: any) => ({ bookings = data.bookings as Booking[];
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
}));
totalBookings = data.total || 0; totalBookings = data.total || 0;
totalPages = data.totalPages || 1; totalPages = data.total_pages || 1;
currentPage = data.page || 1; currentPage = data.page || 1;
} else { } else {
const text = await response.text(); const text = await response.text();
@@ -222,7 +150,7 @@
// Format services list // Format services list
function formatServices(services: Booking['services']): string { 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 === 0) return 'No services';
if (serviceNames.length === 1) return serviceNames[0]; if (serviceNames.length === 1) return serviceNames[0];
if (serviceNames.length === 2) return serviceNames.join(' and '); if (serviceNames.length === 2) return serviceNames.join(' and ');
@@ -232,7 +160,7 @@
// Get status badge classes // Get status badge classes
function getStatusClasses(status: Booking['status']): string { function getStatusClasses(status: Booking['status']): string {
const baseClasses = 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium'; const baseClasses = 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium';
const statusMap = { const statusMap: Record<string, string> = {
confirmed: 'bg-emerald-100 text-emerald-800', confirmed: 'bg-emerald-100 text-emerald-800',
pending: 'bg-amber-100 text-amber-800', pending: 'bg-amber-100 text-amber-800',
in_progress: 'bg-blue-100 text-blue-800', in_progress: 'bg-blue-100 text-blue-800',
@@ -240,13 +168,14 @@
client_cancelled: 'bg-red-100 text-red-800', client_cancelled: 'bg-red-100 text-red-800',
we_cancelled: 'bg-rose-100 text-rose-800', we_cancelled: 'bg-rose-100 text-rose-800',
're-schedule': 'bg-purple-100 text-purple-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'}`; return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`;
} }
function getStatusDotClasses(status: Booking['status']): string { function getStatusDotClasses(status: Booking['status']): string {
const statusMap = { const statusMap: Record<string, string> = {
confirmed: 'bg-emerald-600', confirmed: 'bg-emerald-600',
pending: 'bg-amber-600', pending: 'bg-amber-600',
in_progress: 'bg-blue-600', in_progress: 'bg-blue-600',
@@ -254,7 +183,8 @@
client_cancelled: 'bg-red-600', client_cancelled: 'bg-red-600',
we_cancelled: 'bg-rose-600', we_cancelled: 'bg-rose-600',
're-schedule': 'bg-purple-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'}`; return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
} }
@@ -651,17 +651,99 @@
async function submitBooking() { async function submitBooking() {
isSubmitting = true; isSubmitting = true;
try { try {
console.log('Submitting booking:', { // Build the start_time in ISO format
services: selectedServices, if (!selectedDate || !selectedTime) {
date: selectedDate, toast.error('Please select a date and time');
time: selectedTime, return;
customer: authStore.isAuthenticated ? authStore.currentUser : customerInfo }
// 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) { } catch (error) {
console.error('Booking submission failed:', error); console.error('Booking submission error:', error);
toast.error('Failed to submit booking. Please try again.'); toast.error('Network error. Please check your connection and try again.');
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
+91
View File
@@ -39,3 +39,94 @@ export interface AvailableHoursDay {
slots: Array<{ startTime: string; endTime: string }>; slots: Array<{ startTime: string; endTime: string }>;
source: 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;
}