@@ -11,7 +11,7 @@ import {
1111 UserCheck , BarChart3 , Key , Zap , CheckCheck , ArrowRight , Sun , Moon , ClipboardList , Wallet , UserSearch , Shield , BarChart2 ,
1212 BookOpen , Link2 , Brain , Gavel , ClipboardCheck , SendToBack , Smartphone , Download , Link as LinkIcon , BellRing , ShieldAlert ,
1313 FileBarChart , TrendingUp , Landmark , Scale , Lock , Server , Workflow , Database as DbIcon , ArrowLeftRight , Coins ,
14- Map
14+ Map , Loader2
1515} from 'lucide-react' ;
1616import { Badge } from '@/components/ui/badge' ;
1717import { Button } from '@/components/ui/button' ;
@@ -23,6 +23,7 @@ import { useAuth } from '@/_core/hooks/useAuth';
2323import { useEventStream } from '@/hooks/useEventStream' ;
2424import { GlobalSearchBar } from '@/components/GlobalSearchBar' ;
2525import { useSessionHeartbeat } from '@/hooks/useSessionHeartbeat' ;
26+ import { toast } from 'sonner' ;
2627
2728// ─── Nav config ───────────────────────────────────────────────────────────────
2829
@@ -214,6 +215,9 @@ interface Notification {
214215 body : string ;
215216 time : string ;
216217 ref ?: string ;
218+ href ?: string ;
219+ source ?: 'alert' | 'in_app' ;
220+ approvalId ?: number ;
217221 read : boolean ;
218222}
219223
@@ -275,12 +279,14 @@ function NotificationPanel({
275279 notifications,
276280 onMarkRead,
277281 onMarkAllRead,
282+ onForceCreditDecision,
278283 onClose,
279284 onNavigate,
280285} : {
281286 notifications : Notification [ ] ;
282287 onMarkRead : ( id : string ) => void ;
283288 onMarkAllRead : ( ) => void ;
289+ onForceCreditDecision : ( approvalId : number , decision : 'approve' | 'reject' ) => void ;
284290 onClose : ( ) => void ;
285291 onNavigate : ( href : string ) => void ;
286292} ) {
@@ -350,14 +356,20 @@ function NotificationPanel({
350356 < p className = "text-xs font-mono font-semibold text-foreground leading-tight" > { notif . title } </ p >
351357 < p className = "text-[11px] text-muted-foreground mt-0.5 leading-snug line-clamp-2" > { notif . body } </ p >
352358 < div className = "flex items-center gap-2 mt-1.5" >
353- { notif . ref && (
359+ { ( notif . ref || notif . href ) && (
354360 < button
355- onClick = { ( ) => { onNavigate ( '/investigations' ) ; onClose ( ) ; } }
361+ onClick = { ( ) => { onNavigate ( notif . href ?? '/investigations' ) ; onClose ( ) ; } }
356362 className = "flex items-center gap-1 text-[10px] font-mono text-primary hover:text-primary/80 transition-colors"
357363 >
358- < ArrowRight size = { 9 } /> View { notif . ref }
364+ < ArrowRight size = { 9 } /> { notif . href ? 'Review approval' : ` View $ {notif . ref } ` }
359365 </ button >
360366 ) }
367+ { ! notif . read && notif . approvalId && (
368+ < >
369+ < button onClick = { ( ) => onForceCreditDecision ( notif . approvalId ! , 'approve' ) } className = "text-[10px] font-mono text-emerald-500 hover:text-emerald-400 transition-colors" > Approve</ button >
370+ < button onClick = { ( ) => onForceCreditDecision ( notif . approvalId ! , 'reject' ) } className = "text-[10px] font-mono text-red-500 hover:text-red-400 transition-colors" > Reject</ button >
371+ </ >
372+ ) }
361373 { ! notif . read && (
362374 < button
363375 onClick = { ( ) => onMarkRead ( notif . id ) }
@@ -388,6 +400,26 @@ function NotificationPanel({
388400 ) ;
389401}
390402
403+ function ForceCreditDecisionPrompt ( { decision, onClose, onSubmit, isPending } : { decision : { approvalId : number ; decision : 'approve' | 'reject' } ; onClose : ( ) => void ; onSubmit : ( note : string , totpCode : string ) => void ; isPending : boolean } ) {
404+ const [ note , setNote ] = useState ( "" ) ;
405+ const [ totpCode , setTotpCode ] = useState ( "" ) ;
406+ const approving = decision . decision === 'approve' ;
407+ const valid = note . trim ( ) . length >= 10 && ( ! approving || / ^ \d { 6 } $ / . test ( totpCode ) ) ;
408+ return (
409+ < div className = "fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4" role = "dialog" aria-modal = "true" aria-label = { approving ? "Approve Force Credit" : "Reject Force Credit" } >
410+ < div className = "w-full max-w-md rounded-xl border bg-popover shadow-2xl" >
411+ < div className = "flex items-center justify-between border-b px-4 py-3" > < div > < h3 className = "text-sm font-semibold" > { approving ? 'Authorize Force Credit' : 'Reject Force Credit' } </ h3 > < p className = "text-xs text-muted-foreground" > Approval request #{ decision . approvalId } </ p > </ div > < Button variant = "ghost" size = "icon" onClick = { onClose } disabled = { isPending } > < X size = { 15 } /> </ Button > </ div >
412+ < div className = "space-y-3 p-4" >
413+ < label className = "block text-xs font-medium" > { approving ? 'Authorization note' : 'Rejection note' } < textarea value = { note } onChange = { ( event ) => setNote ( event . target . value ) } className = "mt-1 min-h-20 w-full rounded-md border bg-background p-2 text-sm" placeholder = "State the basis for this decision (minimum 10 characters)" /> </ label >
414+ { approving && < label className = "block text-xs font-medium" > Authenticator code< input value = { totpCode } onChange = { ( event ) => setTotpCode ( event . target . value . replace ( / \D / g, '' ) . slice ( 0 , 6 ) ) } inputMode = "numeric" autoComplete = "one-time-code" className = "mt-1 w-full rounded-md border bg-background p-2 font-mono tracking-[0.35em] text-sm" placeholder = "000000" /> </ label > }
415+ { approving && < p className = "rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-300" > A fresh six-digit code from your verified authenticator app is required. The server validates it before recording any ledger credit.</ p > }
416+ </ div >
417+ < div className = "flex justify-end gap-2 border-t p-4" > < Button variant = "outline" onClick = { onClose } disabled = { isPending } > Cancel</ Button > < Button variant = { approving ? 'default' : 'destructive' } disabled = { ! valid || isPending } onClick = { ( ) => onSubmit ( note . trim ( ) , totpCode ) } > { isPending ? < Loader2 className = "mr-1 h-4 w-4 animate-spin" /> : null } { approving ? 'Approve with MFA' : 'Reject request' } </ Button > </ div >
418+ </ div >
419+ </ div >
420+ ) ;
421+ }
422+
391423// ─── Main Layout ──────────────────────────────────────────────────────────────
392424
393425interface BISLayoutProps {
@@ -403,6 +435,7 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
403435 const [ sidebarOpen , setSidebarOpen ] = useState ( true ) ;
404436 const [ bellOpen , setBellOpen ] = useState ( false ) ;
405437 const [ notifications , setNotifications ] = useState < Notification [ ] > ( [ ] ) ;
438+ const [ forceCreditDecision , setForceCreditDecision ] = useState < { approvalId : number ; decision : 'approve' | 'reject' } | null > ( null ) ;
406439 const { theme, toggleTheme } = useTheme ( ) ;
407440 const { user, isAuthenticated } = useAuth ( ) ;
408441 // Keep session alive while the user is actively using the app
@@ -425,25 +458,51 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
425458 { refetchInterval : 30_000 , staleTime : 15_000 }
426459 ) ;
427460 const { data : notifUnreadData } = trpc . notifications . unreadCount . useQuery ( undefined , {
428- refetchInterval : 60_000 ,
429- staleTime : 30_000 ,
461+ refetchInterval : 5_000 ,
462+ staleTime : 0 ,
430463 } ) ;
464+ const { data : persistedNotificationsData } = trpc . notifications . list . useQuery (
465+ { unreadOnly : false , limit : 20 , offset : 0 } ,
466+ { refetchInterval : 5_000 , staleTime : 0 }
467+ ) ;
431468
432- // Sync live alerts into notification state
469+ // ── Live unreconciled count for Reconciliation badge ─────────────────────
470+ const { data : reconData } = trpc . admin . reconciliation . listUnreconciled . useQuery (
471+ { limit : 1 } ,
472+ { refetchInterval : 30_000 , staleTime : 15_000 }
473+ ) ;
474+
475+ // Merge live alerts with persisted user-scoped notifications. The persisted
476+ // branch is how designated approvers receive Force Credit requests.
433477 useEffect ( ( ) => {
434- if ( ! alertsData ) return ;
435- setNotifications (
436- alertsData . map ( ( a : any ) => ( {
478+ const alertNotifications : Notification [ ] = ( alertsData ?? [ ] ) . map ( ( a : any ) => ( {
437479 id : String ( a . id ) ,
438480 severity : alertSeverityToNotif ( a . severity ) ,
439481 title : a . title ,
440482 body : a . body ?? '' ,
441483 time : new Date ( a . createdAt ) . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ,
442484 ref : a . investigationRef ?? undefined ,
485+ source : 'alert' ,
443486 read : ! ! a . acknowledged ,
444- } ) )
445- ) ;
446- } , [ alertsData ] ) ;
487+ } ) ) ;
488+ const inAppNotifications : Notification [ ] = ( persistedNotificationsData ?. notifications ?? [ ] ) . map ( ( notification : any ) => {
489+ const approvalId = notification . type === 'force_credit_approval_requested'
490+ ? Number ( new URL ( notification . link ?? '' , window . location . origin ) . searchParams . get ( 'approvalId' ) )
491+ : NaN ;
492+ return {
493+ id : `in-app-${ notification . id } ` ,
494+ severity : notification . type === 'force_credit_approval_requested' ? 'high' : 'low' ,
495+ title : notification . title ,
496+ body : notification . body ?? '' ,
497+ time : new Date ( notification . createdAt ) . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) ,
498+ href : notification . link ?? undefined ,
499+ source : 'in_app' ,
500+ approvalId : Number . isInteger ( approvalId ) && approvalId > 0 ? approvalId : undefined ,
501+ read : ! ! notification . read ,
502+ } ;
503+ } ) ;
504+ setNotifications ( [ ...inAppNotifications , ...alertNotifications ] ) ;
505+ } , [ alertsData , persistedNotificationsData ] ) ;
447506
448507 // ── SSE event stream — instant invalidation on new alerts ─────────────────
449508 const utils = trpc . useUtils ( ) ;
@@ -474,6 +533,22 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
474533 } ,
475534 } ) ;
476535
536+ // User-scoped PostgreSQL LISTEN/NOTIFY stream. The database remains the source
537+ // of truth; this event only invalidates cached dropdown data immediately.
538+ useEffect ( ( ) => {
539+ if ( ! isAuthenticated ) return ;
540+ const stream = new EventSource ( "/api/notifications/stream" ) ;
541+ const refreshNotifications = ( ) => {
542+ utils . notifications . list . invalidate ( ) ;
543+ utils . notifications . unreadCount . invalidate ( ) ;
544+ } ;
545+ stream . addEventListener ( "notification" , refreshNotifications ) ;
546+ return ( ) => {
547+ stream . removeEventListener ( "notification" , refreshNotifications ) ;
548+ stream . close ( ) ;
549+ } ;
550+ } , [ isAuthenticated , utils ] ) ;
551+
477552 // ── Web Push: register SW + request permission + register token ────────────
478553 const registerTokenMutation = trpc . push . registerToken . useMutation ( ) ;
479554
@@ -529,17 +604,59 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
529604 const markReadMutation = trpc . alerts . acknowledge . useMutation ( {
530605 onSuccess : ( ) => utils . alerts . list . invalidate ( ) ,
531606 } ) ;
607+ const markInAppReadMutation = trpc . notifications . markRead . useMutation ( {
608+ onSuccess : ( ) => {
609+ utils . notifications . list . invalidate ( ) ;
610+ utils . notifications . unreadCount . invalidate ( ) ;
611+ } ,
612+ } ) ;
613+ const markAllInAppReadMutation = trpc . notifications . markAllRead . useMutation ( {
614+ onSuccess : ( ) => {
615+ utils . notifications . list . invalidate ( ) ;
616+ utils . notifications . unreadCount . invalidate ( ) ;
617+ } ,
618+ } ) ;
619+ const dismissApprovalNotification = ( approvalId : number ) => {
620+ const notification = notifications . find ( ( item ) => item . approvalId === approvalId && ! item . read ) ;
621+ if ( notification ) markRead ( notification . id ) ;
622+ } ;
623+ const approveForceCreditMutation = trpc . admin . reconciliation . approveForceCredit . useMutation ( {
624+ onSuccess : ( result ) => {
625+ toast . success ( `Force Credit approved and recorded as ${ result . transferId } ` ) ;
626+ if ( forceCreditDecision ) dismissApprovalNotification ( forceCreditDecision . approvalId ) ;
627+ setForceCreditDecision ( null ) ;
628+ utils . admin . reconciliation . listForceCreditApprovals . invalidate ( ) ;
629+ utils . notifications . list . invalidate ( ) ;
630+ utils . notifications . unreadCount . invalidate ( ) ;
631+ } ,
632+ onError : ( error ) => toast . error ( error . message || "Force Credit approval failed" ) ,
633+ } ) ;
634+ const rejectForceCreditMutation = trpc . admin . reconciliation . rejectForceCredit . useMutation ( {
635+ onSuccess : ( ) => {
636+ toast . success ( "Force Credit request rejected" ) ;
637+ if ( forceCreditDecision ) dismissApprovalNotification ( forceCreditDecision . approvalId ) ;
638+ setForceCreditDecision ( null ) ;
639+ utils . admin . reconciliation . listForceCreditApprovals . invalidate ( ) ;
640+ utils . notifications . list . invalidate ( ) ;
641+ utils . notifications . unreadCount . invalidate ( ) ;
642+ } ,
643+ onError : ( error ) => toast . error ( error . message || "Force Credit rejection failed" ) ,
644+ } ) ;
532645
533646 const markRead = ( id : string ) => {
534647 setNotifications ( prev => prev . map ( n => n . id === id ? { ...n , read : true } : n ) ) ;
535- markReadMutation . mutate ( { id : parseInt ( id , 10 ) } ) ;
648+ if ( id . startsWith ( 'in-app-' ) ) {
649+ markInAppReadMutation . mutate ( { id : parseInt ( id . replace ( 'in-app-' , '' ) , 10 ) } ) ;
650+ } else {
651+ markReadMutation . mutate ( { id : parseInt ( id , 10 ) } ) ;
652+ }
536653 } ;
537654
538655 const markAllRead = ( ) => {
539656 setNotifications ( prev => prev . map ( n => ( { ...n , read : true } ) ) ) ;
540- notifications
541- . filter ( n => ! n . read )
657+ notifications . filter ( n => ! n . read && n . source === 'alert' )
542658 . forEach ( n => markReadMutation . mutate ( { id : parseInt ( n . id , 10 ) } ) ) ;
659+ if ( notifications . some ( n => ! n . read && n . source === 'in_app' ) ) markAllInAppReadMutation . mutate ( ) ;
543660 } ;
544661
545662 // ── Build nav groups with live badges (filter admin-only items) ────────────
@@ -568,6 +685,9 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
568685 if ( item . href === '/notifications' && notifUnreadData ?. count ) {
569686 return { ...item , badge : notifUnreadData . count } ;
570687 }
688+ if ( item . href === '/payment-rails/reconciliation' && reconData ?. stats ?. total ) {
689+ return { ...item , badge : reconData . stats . total , badgeVariant : 'destructive' as const } ;
690+ }
571691 return item ;
572692 } ) ,
573693 // Filter out admin-only items for non-admin users
@@ -726,10 +846,25 @@ export default function BISLayout({ children, title, subtitle, actions }: BISLay
726846 notifications = { notifications }
727847 onMarkRead = { markRead }
728848 onMarkAllRead = { markAllRead }
849+ onForceCreditDecision = { ( approvalId , decision ) => setForceCreditDecision ( { approvalId, decision } ) }
729850 onClose = { ( ) => setBellOpen ( false ) }
730851 onNavigate = { navigate }
731852 />
732853 ) }
854+ { forceCreditDecision && (
855+ < ForceCreditDecisionPrompt
856+ decision = { forceCreditDecision }
857+ onClose = { ( ) => setForceCreditDecision ( null ) }
858+ onSubmit = { ( note , totpCode ) => {
859+ if ( forceCreditDecision . decision === 'approve' ) {
860+ approveForceCreditMutation . mutate ( { approvalId : forceCreditDecision . approvalId , approvalNote : note , totpCode } ) ;
861+ } else {
862+ rejectForceCreditMutation . mutate ( { approvalId : forceCreditDecision . approvalId , rejectionNote : note } ) ;
863+ }
864+ } }
865+ isPending = { approveForceCreditMutation . isPending || rejectForceCreditMutation . isPending }
866+ />
867+ ) }
733868 </ div >
734869 ) ;
735870}
0 commit comments