feat: admin notification system with priority ordering, bell icon, and /notifications page

Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today).
Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time.
Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers).
Add 15 new tests covering priority ordering, enrichment, and notification creation flows.
Update Admin Manual, Technical Manual, and gap backlog docs.
This commit is contained in:
2026-05-16 23:41:18 +01:00
parent c3501ae89a
commit 7fc58f58d9
19 changed files with 1608 additions and 106 deletions
@@ -62,6 +62,51 @@
let overlappingBookings = $state<OverlappingBooking[]>([]);
let loadingOverlaps = $state(false);
function getBookingDateTime(): string {
if (!booking?.start_time) return '';
const d = new Date(booking.start_time);
return d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}) + ' at ' + d.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function getTotalCost(): number {
if (!booking?.services?.length) return 0;
let total = 0;
for (const service of booking.services) {
if (!service?.service_id) continue;
const override = serviceOverrides[service.service_id];
if (override && hasPriceChanged(service.service_id)) {
total += parseFloat(override.price) || 0;
} else {
total += service.price || 0;
}
}
return Math.round(total * 100) / 100;
}
function getTotalDuration(): number {
if (!booking?.services?.length) return 0;
let total = 0;
for (const service of booking.services) {
if (!service?.service_id) continue;
const override = serviceOverrides[service.service_id];
if (override && hasDurationChanged(service.service_id)) {
total += parseInt(override.duration) || 0;
} else {
total += service.duration_minutes || 0;
}
}
return total;
}
interface OverlappingBooking {
id: string;
start_time: string;
@@ -401,29 +446,36 @@
</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">
Customer Contact
</h3>
<div class="space-y-2">
<!-- 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">
Customer Contact
</h3>
<div class="space-y-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{booking.user?.phone || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{booking.user?.email || '—'}</div>
</div>
</div>
</div>
</div>
<!-- Booking Date & Time -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Booking Date & Time
</h3>
<div class="text-lg font-medium">{getBookingDateTime()}</div>
</div>
<!-- Booking Notes -->
<div>
@@ -546,14 +598,19 @@
</div>
<Modal.Footer class="flex items-center justify-between gap-2">
<Button
variant="destructive"
onclick={() => (showDeclineConfirm = true)}
disabled={submitting}
>
Decline Booking
</Button>
<div class="flex gap-2">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span class="font-medium">Total: £{getTotalCost().toFixed(2)}</span>
<span class="text-gray-400">|</span>
<span>{getTotalDuration()} min</span>
</div>
<div class="flex items-center gap-2">
<Button
variant="destructive"
onclick={() => (showDeclineConfirm = true)}
disabled={submitting}
>
Decline Booking
</Button>
<Button
onclick={handleApprove}
disabled={submitting}