Phase 5 (usability + compliance): - In-app notification bell/badge: migration 016 adds read state + per-user RLS + create_in_app_notification SECURITY DEFINER RPC; /api/notifications; wired into incident creation, CAPA assign/verify, escalation cron - Incident closure: new POST /api/incidents/[id]/close (requires verification status + all CAPAs verified); migration 017 locks closed incidents at DB level (update/delete triggers) with append-only incident_addenda + UI panel - Server-side pagination on HSE/supervisor incident inboxes (.range, 25/page) - Investigation form: alcohol/urine test result + witness statement refs (existing schema columns, now editable) - Type-specific intake fields: migration 018 adds incidents.type_details JSONB; whitelist validation; environmental/asset/security/fire field groups in report form; EN/MS/ZH labels; offline queue support - JKKP 8 annual register CSV export (/api/reports/jkkp8) + dashboard button + January statutory deadline banner - Admin page: user invite (service-role client), role/site/active management, site + zone CRUD with QR report links — replaces Phase 0 stub - Evidence gallery thumbnails via Supabase render transform with fallback Phase 6 (analytics): - 12-month stacked trend chart (leading/lagging/other) + top root causes (lib/dashboard/trends.ts pure helpers) - AI rising-risk zones: /api/dashboard/ai/risk-flags aggregates 90-day zone stats, claude-opus-4-8 forced tool_use, panel on HSE + management dashboards, suggestion audit-logged Also fixes 9 pre-existing missing /ims basePath prefixes in client fetches and download links. 132 tests passing, tsc clean, next build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import Link from 'next/link'
|
|
|
|
type Notification = {
|
|
id: string
|
|
title: string | null
|
|
link: string | null
|
|
sent_at: string
|
|
read_at: string | null
|
|
}
|
|
|
|
const POLL_MS = 60_000
|
|
|
|
export function NotificationBell() {
|
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
|
const [unread, setUnread] = useState(0)
|
|
const [open, setOpen] = useState(false)
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
|
|
const load = useCallback(() => {
|
|
fetch('/ims/api/notifications')
|
|
.then(r => (r.ok ? r.json() : Promise.reject()))
|
|
.then((data: { notifications: Notification[]; unread: number }) => {
|
|
setNotifications(data.notifications)
|
|
setUnread(data.unread)
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
const id = setInterval(load, POLL_MS)
|
|
return () => clearInterval(id)
|
|
}, [load])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onClick = (e: MouseEvent) => {
|
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', onClick)
|
|
return () => document.removeEventListener('mousedown', onClick)
|
|
}, [open])
|
|
|
|
const markAllRead = () => {
|
|
fetch('/ims/api/notifications', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ all: true }),
|
|
})
|
|
.then(() => {
|
|
setUnread(0)
|
|
setNotifications(prev => prev.map(n => ({ ...n, read_at: n.read_at ?? new Date().toISOString() })))
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
return (
|
|
<div ref={panelRef} className="fixed top-3 right-3 z-50">
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
aria-label={`Notifications${unread > 0 ? ` (${unread} unread)` : ''}`}
|
|
className="relative bg-white rounded-full shadow-sm border border-gray-200 w-10 h-10 flex items-center justify-center hover:shadow-md transition-shadow"
|
|
>
|
|
<span className="text-lg" aria-hidden>🔔</span>
|
|
{unread > 0 && (
|
|
<span className="absolute -top-1 -right-1 bg-red-600 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center">
|
|
{unread > 99 ? '99+' : unread}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 mt-2 w-80 max-h-96 overflow-y-auto bg-white rounded-xl shadow-lg border border-gray-200">
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100">
|
|
<span className="text-sm font-semibold text-gray-700">Notifications</span>
|
|
{unread > 0 && (
|
|
<button onClick={markAllRead} className="text-xs text-blue-600 hover:underline">
|
|
Mark all read
|
|
</button>
|
|
)}
|
|
</div>
|
|
{notifications.length === 0 ? (
|
|
<p className="px-4 py-6 text-sm text-gray-400 text-center">No notifications</p>
|
|
) : (
|
|
<ul>
|
|
{notifications.map(n => {
|
|
const inner = (
|
|
<>
|
|
<p className={`text-sm ${n.read_at ? 'text-gray-500' : 'text-gray-900 font-medium'}`}>
|
|
{n.title ?? 'Notification'}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-0.5">
|
|
{new Date(n.sent_at).toLocaleString()}
|
|
</p>
|
|
</>
|
|
)
|
|
return (
|
|
<li key={n.id} className="border-b border-gray-50 last:border-0">
|
|
{n.link ? (
|
|
<Link href={n.link} className="block px-4 py-3 hover:bg-gray-50" onClick={() => setOpen(false)}>
|
|
{inner}
|
|
</Link>
|
|
) : (
|
|
<div className="px-4 py-3">{inner}</div>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|