5f06256271
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import {
|
|
fetchAdminNotifications,
|
|
formatNotificationDate,
|
|
type NotificationsPageResult,
|
|
} from '@/lib/adminNotifications'
|
|
import { ADMIN_API_BASE } from '@/lib/api'
|
|
|
|
interface AdminInboxItem {
|
|
id: string
|
|
readAt: string | null
|
|
createdAt: string
|
|
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
|
|
}
|
|
|
|
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
|
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
|
|
|
|
const CHANNEL_BADGE: Record<string, string> = {
|
|
EMAIL: 'text-sky-400 bg-sky-950/40',
|
|
SMS: 'text-emerald-400 bg-emerald-950/40',
|
|
WHATSAPP: 'text-teal-400 bg-teal-950/40',
|
|
IN_APP: 'text-violet-400 bg-violet-950/40',
|
|
PUSH: 'text-orange-400 bg-orange-950/40',
|
|
}
|
|
|
|
const STATUS_BADGE: Record<string, string> = {
|
|
PENDING: 'text-yellow-400 bg-yellow-950/40',
|
|
SENT: 'text-emerald-400 bg-emerald-950/40',
|
|
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
|
FAILED: 'text-red-400 bg-red-950/40',
|
|
QUEUED: 'text-sky-400 bg-sky-950/40',
|
|
SKIPPED: 'text-zinc-400 bg-zinc-800',
|
|
DEAD_LETTER: 'text-red-300 bg-red-950/60',
|
|
READ: 'text-zinc-400 bg-zinc-800',
|
|
}
|
|
|
|
function formatRecipient(item: {
|
|
recipientType: string | null
|
|
recipientName: string | null
|
|
recipientEmail: string | null
|
|
employeeId: string | null
|
|
renterId: string | null
|
|
}) {
|
|
const fallbackId = item.employeeId ?? item.renterId
|
|
const label = item.recipientName || item.recipientEmail || (fallbackId ? `${fallbackId.slice(0, 10)}...` : '-')
|
|
return item.recipientType ? `${item.recipientType}: ${label}` : label
|
|
}
|
|
|
|
export default function AdminNotificationsPage() {
|
|
const [result, setResult] = useState<NotificationsPageResult | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [filterChannel, setFilterChannel] = useState('')
|
|
const [filterStatus, setFilterStatus] = useState('')
|
|
const [filterCompany, setFilterCompany] = useState('')
|
|
const [page, setPage] = useState(1)
|
|
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
|
|
|
|
function loadInbox() {
|
|
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
|
|
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
|
|
.then((json) => setInbox(json.data))
|
|
.catch(() => {})
|
|
}
|
|
|
|
function load(p: number, signal?: AbortSignal) {
|
|
setLoading(true)
|
|
setError(null)
|
|
fetchAdminNotifications(
|
|
p,
|
|
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
|
|
signal,
|
|
)
|
|
.then((data) => setResult(data))
|
|
.catch((err) => {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
setResult(null)
|
|
setError(err instanceof Error ? err.message : 'Failed to load notifications')
|
|
})
|
|
.finally(() => {
|
|
if (!signal?.aborted) setLoading(false)
|
|
})
|
|
}
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
setPage(1)
|
|
load(1, controller.signal)
|
|
loadInbox()
|
|
return () => controller.abort()
|
|
}, [filterChannel, filterStatus, filterCompany])
|
|
|
|
function goToPage(p: number) {
|
|
setPage(p)
|
|
load(p)
|
|
}
|
|
|
|
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
|
|
|
|
async function markRead(recipientId: string) {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
if (response.ok) loadInbox()
|
|
}
|
|
|
|
return (
|
|
<div className="shell py-8 space-y-6">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
|
<h1 className="mt-1 text-3xl font-black">Notifications</h1>
|
|
<p className="mt-1 text-sm text-zinc-400">
|
|
Full audit log of every notification sent across all companies.
|
|
</p>
|
|
</div>
|
|
{result && (
|
|
<span className="rounded-full bg-zinc-800 px-3 py-1 text-sm text-zinc-300">
|
|
{result.total.toLocaleString()} total
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<section className="panel p-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
|
|
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
|
|
</div>
|
|
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
|
|
</div>
|
|
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
|
|
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
|
{inbox.data.map((item) => (
|
|
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
|
|
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
|
|
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
|
|
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<select
|
|
value={filterChannel}
|
|
onChange={(e) => setFilterChannel(e.target.value)}
|
|
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
>
|
|
<option value="">All channels</option>
|
|
{CHANNELS.map((ch) => <option key={ch} value={ch}>{ch}</option>)}
|
|
</select>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
|
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
>
|
|
<option value="">All statuses</option>
|
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
<input
|
|
value={filterCompany}
|
|
onChange={(e) => setFilterCompany(e.target.value)}
|
|
placeholder="Company ID"
|
|
className="w-64 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
/>
|
|
</div>
|
|
|
|
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
|
|
|
<div className="panel overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-zinc-800">
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Recipient</th>
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Sent at</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-800/60">
|
|
{loading ? (
|
|
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
|
) : !result || result.data.length === 0 ? (
|
|
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
|
) : result.data.map((item) => (
|
|
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
|
{formatNotificationDate(item.createdAt)}
|
|
</td>
|
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
|
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
|
</td>
|
|
<td className="max-w-[220px] px-5 py-3 text-xs text-zinc-300">
|
|
<p className="truncate">{formatRecipient(item)}</p>
|
|
{item.recipientEmail && item.recipientName ? (
|
|
<p className="truncate text-zinc-500">{item.recipientEmail}</p>
|
|
) : null}
|
|
</td>
|
|
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
|
{item.type.replaceAll('_', ' ')}
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${CHANNEL_BADGE[item.channel] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
|
{item.channel}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3 max-w-[220px]">
|
|
<p className="truncate text-sm text-zinc-200">{item.title}</p>
|
|
<p className="truncate text-xs text-zinc-500">{item.body}</p>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[item.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
|
{item.status}
|
|
</span>
|
|
</td>
|
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
|
{formatNotificationDate(item.sentAt)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
|
|
<span className="text-xs text-zinc-500">
|
|
Page {page} of {totalPages} — {result?.total.toLocaleString()} records
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
disabled={page <= 1}
|
|
onClick={() => goToPage(page - 1)}
|
|
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
disabled={page >= totalPages}
|
|
onClick={() => goToPage(page + 1)}
|
|
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|