Files
Crussell/frontend/src/routes/admin/notifications/+page.svelte
T
popertots ad0ad253ad
Backend CI / Lint & vulns (push) Failing after 1m59s
Backend CI / Tests (push) Successful in 2m1s
Backend CI / Race detector (push) Failing after 4m0s
style: apply prettier formatting to frontend
2026-06-25 13:26:26 +01:00

535 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
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 { Checkbox } from '$lib/components/ui/checkbox';
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 EditRequestModal from '$lib/components/admin/EditRequestModal.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;
}
interface ServiceItem {
id: string;
name: string;
price: number;
duration_minutes: number;
}
interface EditRequest {
id: string;
booking_id: string;
requested_by: string;
requested_at: string;
notes: string | null;
original: {
start_time: string;
end_time: string;
services: ServiceItem[];
notes: string;
};
proposed: {
start_time: string | null;
end_time: string | null;
services: ServiceItem[];
notes: string | null;
};
user: {
id: string;
full_name: string;
email: string;
phone: 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 showEditRequestModal = $state(false);
let selectedEditRequest = $state<EditRequest | null>(null);
function openBookingModal(bookingId: string) {
selectedBooking = { id: bookingId };
showBookingModal = true;
}
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/Reschedule Requested',
new_booking: 'New Booking Received',
cancelled_booking: 'Booking Cancelled',
late_cancellation: 'Late Cancellation (< 24h)',
deposit_paid: 'Deposit Payment Received',
deposit_not_paid_by_deadline: 'Deposit Deadline Passed',
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 'new_booking':
return 'view';
case 'edit_requested':
return 'edit_approve';
case 'late_cancellation':
case 'deposit_not_paid_by_deadline':
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 === 'edit_approve' && notification.booking_id) {
try {
const response = await fetch(
`/api/admin/bookings/${notification.booking_id}/edit-request`,
{
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}
);
if (response.ok) {
const data = await response.json();
selectedEditRequest = data.edit_request;
showEditRequestModal = true;
} else if (response.status === 404) {
toast.error('This edit/reschedule request has already been processed');
} else {
toast.error('Could not load edit/reschedule request details');
}
} catch (err) {
console.error('Error fetching edit request:', err);
toast.error('Network error loading edit/reschedule request');
}
} 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 handleEditRequestAction() {
showEditRequestModal = false;
selectedEditRequest = 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 SvelteDate(iso);
const now = new SvelteDate();
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 SvelteDate(iso);
const now = new SvelteDate();
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new SvelteDate(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new SvelteDate(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);
{
/* TODO: add formerly name when previous name data is available */
}
}
return parts.join(' — ');
}
const totalPages = $derived(Math.ceil(total / perPage));
onMount(() => {
if (pageState === 'authorized') {
fetchNotifications();
}
});
</script>
<svelte:head>
<script>
(function () {
try {
var token = localStorage.getItem('authToken');
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
return;
}
if (payload.role !== 'admin') {
window.location.replace('/');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
</svelte:head>
{#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">
<Checkbox checked={includeAcknowledged} onchange={toggleView} />
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 {loading ? 'opacity-60' : ''}">
{#each notifications as n (n.id)}
{@const actionable =
!n.acknowledged_at &&
(hasAction(n.reason) === 'approve' || hasAction(n.reason) === 'edit_approve')}
<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'
: actionable
? 'border-amber-300 bg-amber-50/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 if hasAction(n.reason) === 'edit_approve'}
Review Change
{: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}
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
{#if showEditRequestModal && selectedEditRequest}
<EditRequestModal
bind:open={showEditRequestModal}
editRequest={selectedEditRequest}
onApproved={handleEditRequestAction}
onDenied={handleEditRequestAction}
/>
{/if}