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}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<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}
|
||||
Reference in New Issue
Block a user