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:
@@ -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}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { navigating, page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { navigating, page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Centralized link definition
|
||||
const links = [
|
||||
@@ -53,6 +55,40 @@
|
||||
// Derived values for auth state - ensures reactivity
|
||||
let isAuthenticated = $derived(authStore.isAuthenticated);
|
||||
let isLoading = $derived(authStore.isLoading);
|
||||
|
||||
// Notification bell state
|
||||
let unreadCount = $state(0);
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function fetchUnreadCount() {
|
||||
if (!authStore.isAuthenticated || !authStore.currentToken) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/notifications/unread-count', {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
unreadCount = data.count;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - bell is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
fetchUnreadCount();
|
||||
if (authStore.currentUser?.role === 'admin') {
|
||||
pollInterval = setInterval(fetchUnreadCount, 60000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav
|
||||
@@ -104,15 +140,34 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop Login Button -->
|
||||
<div class="hidden items-center md:flex">
|
||||
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
|
||||
<Button href="/login">Login</Button>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<Skeleton class="h-8 w-16 rounded" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="hidden items-center gap-4 md:flex">
|
||||
{#if isAuthenticated}
|
||||
<a
|
||||
href="/notifications"
|
||||
class="relative text-gray-600 hover:text-primary"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
{#if unreadCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
|
||||
<Button href="/login">Login</Button>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<Skeleton class="h-8 w-16 rounded" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Burger -->
|
||||
<div class="flex items-center md:hidden">
|
||||
@@ -149,6 +204,20 @@
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if isAuthenticated}
|
||||
<a
|
||||
href="/notifications"
|
||||
class="flex items-center justify-center gap-2 rounded px-3 py-2 text-primary hover:text-gray-800"
|
||||
>
|
||||
<span>Notifications</span>
|
||||
{#if unreadCount > 0}
|
||||
<span class="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<Skeleton class="mt-2 h-8 w-full rounded" />
|
||||
{:else if !isAuthenticated}
|
||||
|
||||
Reference in New Issue
Block a user