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.
429 lines
12 KiB
Svelte
429 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import { fly } from 'svelte/transition';
|
||
import { cubicOut } from 'svelte/easing';
|
||
import { goto } from '$app/navigation';
|
||
import { browser } from '$app/environment';
|
||
import { authStore } from '$lib/stores/auth.svelte';
|
||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||
import { Button } from '$lib/components/ui/button';
|
||
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||
import UserModal from '$lib/components/admin/UserModal.svelte';
|
||
import { toast } from 'svelte-sonner';
|
||
|
||
interface Notification {
|
||
id: number;
|
||
reason: string;
|
||
booking_id?: string;
|
||
user_id?: string;
|
||
user_name?: string;
|
||
booking_start_time?: string;
|
||
acknowledged_at?: string;
|
||
created_at: string;
|
||
}
|
||
|
||
let notifications = $state<Notification[]>([]);
|
||
let loading = $state(true);
|
||
let error = $state(false);
|
||
let page = $state(1);
|
||
let perPage = $state(20);
|
||
let total = $state(0);
|
||
let includeAcknowledged = $state(false);
|
||
|
||
let showApprovalModal = $state(false);
|
||
let selectedBooking = $state<any>(null);
|
||
let showBookingModal = $state(false);
|
||
let showUserModal = $state(false);
|
||
let selectedUserId = $state<string | null>(null);
|
||
|
||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
if (authStore.isLoading) {
|
||
pageState = 'loading';
|
||
return;
|
||
}
|
||
if (!authStore.isAuthenticated || authStore.currentUser?.role !== 'admin') {
|
||
pageState = 'unauthorized';
|
||
return;
|
||
}
|
||
pageState = 'authorized';
|
||
});
|
||
|
||
const reasonLabels: Record<string, string> = {
|
||
pending_booking: 'Booking Pending Approval',
|
||
edit_request: 'Customer Requested Booking Change',
|
||
edit_requested: 'Booking Edit Requested',
|
||
new_booking: 'New Booking Received',
|
||
cancelled_booking: 'Booking Cancelled',
|
||
late_cancellation: 'Late Cancellation (< 24h)',
|
||
no_deposit: 'Deposit Issue',
|
||
deposit_paid: 'Deposit Payment Received',
|
||
affiliate_claim: 'Affiliate Referral Claimed',
|
||
'1_month_no_pay': 'No Payments in 1 Month',
|
||
'1_week_no_pay': 'No Payments in 1 Week'
|
||
};
|
||
|
||
function hasAction(reason: string): string | null {
|
||
switch (reason) {
|
||
case 'pending_booking':
|
||
return 'approve';
|
||
case 'edit_request':
|
||
case 'edit_requested':
|
||
case 'new_booking':
|
||
return 'view';
|
||
case 'late_cancellation':
|
||
case 'no_deposit':
|
||
case '1_week_no_pay':
|
||
case '1_month_no_pay':
|
||
return 'see_user';
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function fetchNotifications() {
|
||
loading = true;
|
||
error = false;
|
||
try {
|
||
const params = new URLSearchParams({
|
||
page: page.toString(),
|
||
per_page: perPage.toString(),
|
||
include_acknowledged: includeAcknowledged.toString()
|
||
});
|
||
const response = await fetch(`/api/admin/notifications?${params}`, {
|
||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Failed: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
notifications = data.notifications;
|
||
total = data.total;
|
||
} catch {
|
||
error = true;
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
async function fetchBookingDetails(bookingId: string): Promise<any> {
|
||
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
|
||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||
});
|
||
if (!response.ok) return null;
|
||
return response.json();
|
||
}
|
||
|
||
async function handleAction(notification: Notification) {
|
||
if (!notification.acknowledged_at) {
|
||
await fetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${authStore.currentToken}`
|
||
}
|
||
});
|
||
}
|
||
|
||
const action = hasAction(notification.reason);
|
||
if (action === 'approve' && notification.booking_id) {
|
||
const booking = await fetchBookingDetails(notification.booking_id);
|
||
if (booking) {
|
||
selectedBooking = booking;
|
||
showApprovalModal = true;
|
||
} else {
|
||
toast.error('Could not load booking details');
|
||
}
|
||
} else if (action === 'view' && notification.booking_id) {
|
||
const booking = await fetchBookingDetails(notification.booking_id);
|
||
if (booking) {
|
||
selectedBooking = booking;
|
||
showBookingModal = true;
|
||
} else {
|
||
toast.error('Could not load booking details');
|
||
}
|
||
} else if (action === 'see_user' && notification.user_id) {
|
||
selectedUserId = notification.user_id;
|
||
showUserModal = true;
|
||
}
|
||
}
|
||
|
||
async function handleAcknowledge(notification: Notification) {
|
||
try {
|
||
const response = await fetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${authStore.currentToken}`
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
toast.error('Failed to acknowledge notification');
|
||
return;
|
||
}
|
||
notifications = notifications.filter((n) => n.id !== notification.id);
|
||
total = Math.max(0, total - 1);
|
||
if (notifications.length === 0 && page > 1) {
|
||
page--;
|
||
await fetchNotifications();
|
||
}
|
||
} catch {
|
||
toast.error('Network error');
|
||
}
|
||
}
|
||
|
||
function handleApproved() {
|
||
showApprovalModal = false;
|
||
selectedBooking = null;
|
||
fetchNotifications();
|
||
}
|
||
|
||
function toggleView() {
|
||
includeAcknowledged = !includeAcknowledged;
|
||
page = 1;
|
||
fetchNotifications();
|
||
}
|
||
|
||
function prevPage() {
|
||
if (page > 1) {
|
||
page--;
|
||
fetchNotifications();
|
||
}
|
||
}
|
||
|
||
function nextPage() {
|
||
if (page * perPage < total) {
|
||
page++;
|
||
fetchNotifications();
|
||
}
|
||
}
|
||
|
||
function formatRelative(iso: string): string {
|
||
const d = new Date(iso);
|
||
const now = new Date();
|
||
const diffMs = now.getTime() - d.getTime();
|
||
const diffMin = Math.floor(diffMs / 60000);
|
||
const diffHr = Math.floor(diffMin / 60);
|
||
const diffDay = Math.floor(diffHr / 24);
|
||
const diffWeek = Math.floor(diffDay / 7);
|
||
|
||
if (diffMin < 1) return 'Just now';
|
||
if (diffMin < 60) return `${diffMin}m ago`;
|
||
if (diffHr < 24) return `${diffHr}h ago`;
|
||
if (diffDay < 7) return `${diffDay}d ago`;
|
||
if (diffWeek < 5) return `${diffWeek}w ago`;
|
||
return d.toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||
});
|
||
}
|
||
|
||
function formatBookingDate(iso: string): string {
|
||
const d = new Date(iso);
|
||
const now = new Date();
|
||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const tomorrow = new Date(today);
|
||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
|
||
|
||
if (diffDays === 0) return 'Today';
|
||
if (diffDays === 1) return 'Tomorrow';
|
||
if (diffDays === -1) return 'Yesterday';
|
||
if (diffDays > 1 && diffDays <= 6) {
|
||
return d.toLocaleDateString('en-GB', { weekday: 'long' });
|
||
}
|
||
if (diffDays > 6 && diffDays <= 13) {
|
||
return 'Next ' + d.toLocaleDateString('en-GB', { weekday: 'long' });
|
||
}
|
||
return d.toLocaleDateString('en-GB', {
|
||
weekday: 'long',
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||
});
|
||
}
|
||
|
||
function getNotificationTitle(n: Notification): string {
|
||
const base = reasonLabels[n.reason] || n.reason;
|
||
if (n.booking_start_time) {
|
||
return `${base} — ${formatBookingDate(n.booking_start_time)}`;
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function getNotificationSubtitle(n: Notification): string {
|
||
const parts = [formatRelative(n.created_at)];
|
||
if (n.user_name) {
|
||
parts.push(n.user_name);
|
||
}
|
||
return parts.join(' — ');
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / perPage);
|
||
|
||
onMount(() => {
|
||
if (pageState === 'authorized') {
|
||
fetchNotifications();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
{#if pageState === 'loading'}
|
||
<div class="mx-auto max-w-3xl space-y-4 p-6 pt-24">
|
||
<Skeleton class="h-8 w-48" />
|
||
{#each Array(5) as _, i (i)}
|
||
<Skeleton class="h-24 w-full" />
|
||
{/each}
|
||
</div>
|
||
{:else if pageState === 'unauthorized'}
|
||
<div class="flex min-h-screen items-center justify-center p-4">
|
||
<div class="max-w-md text-center">
|
||
<svg
|
||
class="mx-auto mb-4 h-16 w-16 text-gray-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||
/>
|
||
</svg>
|
||
<h2 class="mb-2 text-xl font-semibold text-gray-900">Coming Soon</h2>
|
||
<p class="text-gray-600">
|
||
Notifications are available for admin accounts. This feature will be enabled for all users
|
||
before launch.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{:else if error}
|
||
<div class="flex min-h-screen items-center justify-center p-4">
|
||
<div class="max-w-md text-center">
|
||
<h2 class="mb-2 text-xl font-semibold text-gray-900">Unable to Load Notifications</h2>
|
||
<p class="mb-4 text-gray-600">Something went wrong. Please try again.</p>
|
||
<Button onclick={fetchNotifications}>Retry</Button>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class="mx-auto max-w-3xl p-6 pt-24">
|
||
<div class="mb-6 flex items-center justify-between">
|
||
<h1 class="text-2xl font-semibold text-gray-900">Notifications</h1>
|
||
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
||
<input
|
||
type="checkbox"
|
||
checked={includeAcknowledged}
|
||
onchange={toggleView}
|
||
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||
/>
|
||
Show acknowledged
|
||
</label>
|
||
</div>
|
||
|
||
{#if notifications.length === 0}
|
||
<div class="rounded-lg border border-gray-200 bg-gray-50 py-16 text-center">
|
||
<svg
|
||
class="mx-auto mb-3 h-12 w-12 text-gray-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="1.5"
|
||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||
/>
|
||
</svg>
|
||
<p class="text-gray-500">
|
||
{includeAcknowledged ? 'No notifications yet' : 'No unread notifications'}
|
||
</p>
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-3">
|
||
{#each notifications as n (n.id)}
|
||
<div
|
||
transition:fly={{ x: 100, duration: 250, easing: cubicOut }}
|
||
class="rounded-lg border p-4 transition-colors {n.acknowledged_at
|
||
? 'border-gray-200 bg-gray-50'
|
||
: 'border-gray-300 bg-white'}"
|
||
>
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2">
|
||
{#if !n.acknowledged_at}
|
||
<span class="h-2 w-2 shrink-0 rounded-full bg-primary"></span>
|
||
{/if}
|
||
<h3 class="font-medium text-gray-900">{getNotificationTitle(n)}</h3>
|
||
</div>
|
||
<p class="mt-1 text-sm text-gray-500">{getNotificationSubtitle(n)}</p>
|
||
</div>
|
||
<div class="flex shrink-0 items-center gap-2 sm:flex-col sm:items-end">
|
||
{#if hasAction(n.reason)}
|
||
<Button size="sm" onclick={() => handleAction(n)}>
|
||
{#if hasAction(n.reason) === 'approve'}
|
||
Approve Booking
|
||
{:else if hasAction(n.reason) === 'see_user'}
|
||
See User
|
||
{:else}
|
||
See Booking
|
||
{/if}
|
||
</Button>
|
||
{/if}
|
||
{#if !n.acknowledged_at}
|
||
<Button size="sm" variant="outline" onclick={() => handleAcknowledge(n)}>
|
||
Acknowledge
|
||
</Button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if totalPages > 1}
|
||
<div class="mt-6 flex items-center justify-between">
|
||
<p class="text-sm text-gray-500">
|
||
{(page - 1) * perPage + 1}–{Math.min(page * perPage, total)} of {total}
|
||
</p>
|
||
<div class="flex gap-2">
|
||
<Button size="sm" variant="outline" onclick={prevPage} disabled={page <= 1}>
|
||
Previous
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onclick={nextPage}
|
||
disabled={page * perPage >= total}
|
||
>
|
||
Next
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if showApprovalModal && selectedBooking}
|
||
<ApprovalModal
|
||
bind:open={showApprovalModal}
|
||
booking={selectedBooking}
|
||
onApproved={handleApproved}
|
||
/>
|
||
{/if}
|
||
|
||
{#if showBookingModal && selectedBooking}
|
||
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking.id} />
|
||
{/if}
|
||
|
||
{#if showUserModal && selectedUserId}
|
||
<UserModal bind:open={showUserModal} userId={selectedUserId} />
|
||
{/if}
|