non-booking-time-blockers (nbtb)

This commit is contained in:
2026-03-04 11:38:34 +00:00
parent 861dd11c5b
commit 29b9776a93
12 changed files with 845 additions and 139 deletions
@@ -70,27 +70,50 @@
{/if}
</div>
{#if selectedBooking}
<!-- Logic: Only show chip if Booking is Future OR (Past AND Unpaid) -->
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
{@const isUnpaid = selectedBooking.amount_due > 0}
{@const showChip = !isPastBooking || isUnpaid}
{#if selectedBooking}
<!-- Booking Status Badge -->
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
{@const isUnpaid = selectedBooking.amount_due > 0}
{@const showChip = !isPastBooking || isUnpaid}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
{#if showChip}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{isPastBooking
? 'bg-red-100 text-red-800' // Red if past & unpaid
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
? 'bg-emerald-100 text-emerald-800'
: selectedBooking.status === 'pending'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-100 text-gray-800'}"
>
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
</span>
{#if showChip}
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{isPastBooking
? 'bg-red-100 text-red-800'
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
? 'bg-emerald-100 text-emerald-800'
: selectedBooking.status === 'pending'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-100 text-gray-800'}"
>
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
</span>
<!-- Deposit Status Badge -->
{#if selectedBooking.deposit_required}
{#if selectedBooking.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if isConfirmedOrLater}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if}
</div>
{/if}
{/if}
{/if}
</div>
</Modal.Header>
@@ -138,47 +161,7 @@
</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}
<!-- Deposit Info (removed - now in Financial Summary for confirmed+ bookings) -->
<!-- Services -->
{#if selectedBooking.services && selectedBooking.services.length > 0}
@@ -208,7 +191,40 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary
</h3>
<div class="space-y-2">
<div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<!-- Deposit Info -->
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
<div class="text-xs">
<span
class="{selectedBooking.deposit_paid
? 'text-green-600'
: 'text-orange-600'}"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
@@ -11,6 +11,9 @@
open: boolean;
booking: {
id: string;
start_time: string;
duration_minutes?: number;
created_at: string;
notes?: string;
user?: {
full_name: string;
@@ -56,7 +59,69 @@
>({});
let submitting = $state(false);
let showDeclineConfirm = $state(false);
let overlappingBookings = $state<OverlappingBooking[]>([]);
let loadingOverlaps = $state(false);
interface OverlappingBooking {
id: string;
start_time: string;
duration_minutes: number;
status: string;
created_at: string;
user?: {
full_name?: string;
email?: string;
};
services?: string[];
}
// Fetch overlapping bookings when modal opens
async function fetchOverlappingBookings() {
if (!booking?.id) return;
loadingOverlaps = true;
try {
const response = await fetch(`/api/admin/bookings/${booking.id}/overlapping`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
overlappingBookings = data.bookings || [];
}
} catch (err) {
console.error('Error fetching overlapping bookings:', err);
} finally {
loadingOverlaps = false;
}
}
// Find the oldest booking (including current) among overlaps
function findOldestBooking(): { id: string; isCurrent: boolean } | null {
if (overlappingBookings.length === 0) return null;
const currentCreatedAt = new Date(booking.created_at).getTime();
let oldestId = booking.id;
let oldestTime = currentCreatedAt;
for (const ob of overlappingBookings) {
const obTime = new Date(ob.created_at).getTime();
if (obTime < oldestTime) {
oldestTime = obTime;
oldestId = ob.id;
}
}
return { id: oldestId, isCurrent: oldestId === booking.id };
}
$effect(() => {
if (open && booking?.id) {
fetchOverlappingBookings();
}
});
// Initialize overrides with original values
$effect(() => {
if (!booking?.services?.length) return;
@@ -240,6 +305,79 @@
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Overlapping Bookings Warning -->
{#if loadingOverlaps}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="text-sm text-gray-500">Checking for overlapping bookings...</div>
</div>
{:else if overlappingBookings.length > 0}
{@const oldest = findOldestBooking()}
<div class="rounded-lg border border-amber-300 bg-amber-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-amber-800 uppercase">
⚠️ Overlapping Bookings ({overlappingBookings.length})
</h3>
{#if oldest?.isCurrent}
<div class="mb-3 rounded bg-green-100 px-3 py-2 text-sm text-green-800">
★ This booking was created first - it has priority
</div>
{/if}
<div class="space-y-2">
{#each overlappingBookings as ob (ob.id)}
<div class="rounded border bg-white p-3 text-sm">
<div class="flex items-start justify-between">
<div>
<div class="font-medium">
{#if oldest?.id === ob.id}
<span class="text-amber-600" title="Booked first"></span>
{/if}
{ob.user?.full_name || 'Unknown'}
</div>
<div class="text-xs text-gray-500">
{new Date(ob.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
({ob.duration_minutes} min)
</div>
{#if ob.services && ob.services.length > 0}
<div class="mt-1 text-xs text-gray-600">
{ob.services.join(', ')}
</div>
{/if}
</div>
<div class="text-right">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{ob.status === 'pending'
? 'bg-amber-100 text-amber-800'
: ob.status === 'confirmed'
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
{ob.status}
</span>
<div class="mt-1 text-xs text-gray-400">
{new Date(ob.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Customer Contact Info -->
<!-- Customer Contact Info -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
@@ -138,6 +138,8 @@
{/if}
</div>
{#if selectedBooking}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
{#if selectedBooking.status === 'pending'}
<div class="flex items-center gap-2">
<Button
@@ -165,6 +167,37 @@
>
Pending
</span>
<!-- Deposit Badge for pending -->
{#if selectedBooking.deposit_required}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{/if}
</div>
{:else if isConfirmedOrLater && selectedBooking.deposit_required}
<!-- Deposit Badge for confirmed+ -->
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.status === 'completed'
? 'bg-green-100 text-green-800'
: selectedBooking.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{selectedBooking.status.replace('_', ' ')}
</span>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
</div>
{/if}
{/if}
@@ -229,47 +262,7 @@
{/if}
</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}
<!-- Deposit Info (moved to Financial Summary) -->
<!-- Services -->
{#if selectedBooking.services && selectedBooking.services.length > 0}
@@ -348,7 +341,40 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary
</h3>
<div class="space-y-2">
<div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<!-- Deposit Info -->
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
<div class="text-xs">
<span
class="{selectedBooking.deposit_paid
? 'text-green-600'
: 'text-orange-600'}"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
@@ -246,17 +246,35 @@
<div class="font-medium">
{formatBookingDateTime(b.start_time)}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span>
{b.status}
</span>
<span>{b.user?.full_name || 'Unknown User'}</span>
<span>
- {formatServices(b.services)}
</span>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span>
{b.status}
</span>
{#if b.deposit_required}
{#if b.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{b.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if}
<span>{b.user?.full_name || 'Unknown User'}</span>
<span>
- {formatServices(b.services)}
</span>
</div>
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
@@ -55,7 +55,10 @@
| 'client_cancelled'
| 'we_cancelled'
| 're-schedule'
| 'no_show';
| 'no_show'
| 'no_deposit';
deposit_required: boolean;
deposit_paid: boolean;
services: Array<{
service_name?: string;
}>;
@@ -434,27 +437,51 @@
return `${dateStr} at ${timeStr}`;
})()}
</div>
<div class="mt-1 flex items-center gap-2 text-xs">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{booking.status === 'confirmed'
? 'bg-emerald-100 text-emerald-800'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: booking.status === 'completed'
? 'bg-green-100 text-green-800'
: booking.status === 'cancelled' ||
booking.status === 'client_cancelled' ||
booking.status === 'we_cancelled'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}"
>
{booking.status}
</span>
<span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')}
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{booking.status === 'confirmed' || booking.status === 'in_progress'
? 'bg-emerald-100 text-emerald-800'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: booking.status === 'completed'
? 'bg-green-100 text-green-800'
: booking.status === 'client_cancelled' ||
booking.status === 'we_cancelled' ||
booking.status === 'no_deposit'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}"
>
{booking.status.replace('_', ' ')}
</span>
<!-- Deposit chip: pending = "will require deposit", confirmed+/!paid = "deposit due" -->
{#if booking.deposit_required}
{#if booking.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
>
Deposit Due
</span>
{:else if booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
>
Deposit Paid
</span>
{/if}
{/if}
<span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')}
</span>
</div>
<div class="mt-1 text-sm font-semibold text-gray-900">
£{booking.total_amount.toFixed(2)}
</div>
@@ -728,17 +728,21 @@
// Handle specific error cases
if (response.status === 409) {
toast.error('This time slot is no longer available. Please choose a different time.');
// Check if it's an active booking conflict or time slot conflict
if (errorMessage.includes('active booking') || errorMessage.includes('already have')) {
toast.error('You already have an active booking. Please complete or cancel it before creating a new one.');
} else {
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')) {
} else if (errorMessage.includes('48 hours') || errorMessage.includes('48h') || errorMessage.includes('advance')) {
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) {