Files
carmanagement/apps/admin/src/app/dashboard/notifications/page.tsx
T
root 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
fix billing and 2fa admin
2026-08-10 22:35:55 -04:00

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>
)
}