feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
src/components/alerts/alert-bell.tsx
Normal file
84
src/components/alerts/alert-bell.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
|
||||
export function AlertBell() {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Count is cheap (one aggregate query) — fire on every page so the badge stays live.
|
||||
// List is heavier — only fetch when the popover is actually open.
|
||||
const { data: count } = useAlertCount();
|
||||
const { data: list, isLoading } = useAlertList('open', open);
|
||||
useAlertRealtime();
|
||||
|
||||
const total = count?.total ?? 0;
|
||||
const critical = count?.bySeverity.critical ?? 0;
|
||||
const alerts = list?.data ?? [];
|
||||
const top = alerts.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative"
|
||||
aria-label={`Alerts${total > 0 ? ` (${total} active)` : ''}`}
|
||||
data-testid="alert-bell"
|
||||
>
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
{total > 0 ? (
|
||||
<span
|
||||
key={total}
|
||||
data-testid="alert-bell-badge"
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white shadow-sm ring-2 ring-background animate-badge-pop',
|
||||
critical > 0 ? 'bg-destructive' : 'bg-amber-500',
|
||||
)}
|
||||
>
|
||||
{total > 99 ? '99+' : total}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 p-0">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h4 className="text-sm font-semibold">Active alerts</h4>
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="max-h-[420px]">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : top.length === 0 ? (
|
||||
<div className="p-3">
|
||||
<AlertCardEmpty />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 p-3">
|
||||
{top.map((a) => (
|
||||
<AlertCard key={a.id} alert={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
116
src/components/alerts/alert-card.tsx
Normal file
116
src/components/alerts/alert-card.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, Bell, Check, ExternalLink, Info, ShieldAlert, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AlertRow } from './types';
|
||||
import { useAlertActions } from './use-alerts';
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: AlertRow;
|
||||
/** Hide the side action buttons in compact contexts (e.g. resolved/dismissed history). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const SEVERITY_STYLES: Record<string, { stripe: string; icon: typeof Info }> = {
|
||||
info: { stripe: 'bg-[hsl(var(--chart-1))]', icon: Info },
|
||||
warning: { stripe: 'bg-amber-500', icon: AlertTriangle },
|
||||
critical: { stripe: 'bg-destructive', icon: ShieldAlert },
|
||||
};
|
||||
|
||||
export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
||||
const router = useRouter();
|
||||
const { acknowledge, dismiss } = useAlertActions();
|
||||
const sev = SEVERITY_STYLES[alert.severity] ?? SEVERITY_STYLES.info!;
|
||||
const Icon = sev.icon;
|
||||
const acknowledged = Boolean(alert.acknowledgedAt);
|
||||
const fired = formatDistanceToNow(new Date(alert.firedAt), { addSuffix: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="alert-card"
|
||||
data-severity={alert.severity}
|
||||
className={cn(
|
||||
'group relative flex gap-3 overflow-hidden rounded-lg border border-border bg-card p-3 shadow-xs transition-shadow duration-base ease-spring hover:shadow-sm',
|
||||
acknowledged && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
<span className={cn('absolute inset-y-0 left-0 w-1', sev.stripe)} aria-hidden />
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
alert.severity === 'critical' && 'text-destructive',
|
||||
alert.severity === 'warning' && 'text-amber-600',
|
||||
alert.severity === 'info' && 'text-foreground',
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">{alert.title}</p>
|
||||
{acknowledged ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">ack</span>
|
||||
) : null}
|
||||
</div>
|
||||
{alert.body ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{alert.body}</p>
|
||||
) : null}
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{fired}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono text-[10px]">{alert.ruleId}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
|
||||
{!acknowledged ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="Acknowledge"
|
||||
disabled={acknowledge.isPending}
|
||||
onClick={() => acknowledge.mutate(alert.id)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="Dismiss"
|
||||
disabled={dismiss.isPending}
|
||||
onClick={() => dismiss.mutate(alert.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{alert.link ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="Open"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={() => router.push(alert.link as any)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCardEmpty() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
|
||||
<Bell className="mb-2 h-8 w-8 text-muted-foreground/40" aria-hidden />
|
||||
<p className="text-sm font-medium">All clear</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">No active alerts right now.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/alerts/alert-rail.tsx
Normal file
63
src/components/alerts/alert-rail.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertList, useAlertRealtime } from './use-alerts';
|
||||
|
||||
export function AlertRail() {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const { data, isLoading } = useAlertList('open');
|
||||
useAlertRealtime();
|
||||
|
||||
const alerts = data?.data ?? [];
|
||||
// Show first 5 in the rail; surplus pushes user to the full /alerts page.
|
||||
const visible = alerts.slice(0, 5);
|
||||
const overflow = Math.max(alerts.length - visible.length, 0);
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="alert-rail"
|
||||
aria-label="Active alerts"
|
||||
className="flex h-full flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="ml-1 inline h-3 w-3" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<AlertCardEmpty />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{visible.map((a) => (
|
||||
<AlertCard key={a.id} alert={a} />
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
+{overflow} more — view all
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
66
src/components/alerts/alerts-page-shell.tsx
Normal file
66
src/components/alerts/alerts-page-shell.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
import type { AlertStatus } from './types';
|
||||
|
||||
export function AlertsPageShell() {
|
||||
const [tab, setTab] = useState<AlertStatus>('open');
|
||||
const { data: count } = useAlertCount();
|
||||
const { data, isLoading } = useAlertList(tab);
|
||||
useAlertRealtime();
|
||||
|
||||
const total = count?.total ?? 0;
|
||||
const alerts = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Alerts"
|
||||
eyebrow="Operational"
|
||||
description="Rules-based signals about pipeline, agreements, expenses, and access"
|
||||
kpiLine={
|
||||
<span>
|
||||
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{total} active
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="open" data-testid="tab-open">
|
||||
Active{total > 0 ? ` · ${total}` : ''}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dismissed" data-testid="tab-dismissed">
|
||||
Dismissed
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="resolved" data-testid="tab-resolved">
|
||||
Resolved
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={tab} className="mt-4 space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<AlertCardEmpty />
|
||||
) : (
|
||||
alerts.map((a) => <AlertCard key={a.id} alert={a} readOnly={tab !== 'open'} />)
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/alerts/types.ts
Normal file
14
src/components/alerts/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Alert } from '@/lib/db/schema/insights';
|
||||
|
||||
export type AlertRow = Alert;
|
||||
|
||||
export interface AlertListResponse {
|
||||
data: AlertRow[];
|
||||
}
|
||||
|
||||
export interface AlertCountResponse {
|
||||
total: number;
|
||||
bySeverity: Record<'info' | 'warning' | 'critical', number>;
|
||||
}
|
||||
|
||||
export type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||
50
src/components/alerts/use-alerts.ts
Normal file
50
src/components/alerts/use-alerts.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import type { AlertCountResponse, AlertListResponse, AlertStatus } from './types';
|
||||
|
||||
export function useAlertList(status: AlertStatus = 'open', enabled = true) {
|
||||
return useQuery<AlertListResponse>({
|
||||
queryKey: ['alerts', status],
|
||||
queryFn: () => apiFetch<AlertListResponse>(`/api/v1/alerts?status=${status}`),
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertCount() {
|
||||
return useQuery<AlertCountResponse>({
|
||||
queryKey: ['alerts', 'count'],
|
||||
queryFn: () => apiFetch<AlertCountResponse>('/api/v1/alerts/count'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alerts'] });
|
||||
};
|
||||
|
||||
const acknowledge = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/acknowledge`, { method: 'POST' }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const dismiss = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/dismiss`, { method: 'POST' }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { acknowledge, dismiss };
|
||||
}
|
||||
|
||||
export function useAlertRealtime() {
|
||||
useRealtimeInvalidation({
|
||||
'alert:created': [['alerts']],
|
||||
'alert:resolved': [['alerts']],
|
||||
'alert:dismissed': [['alerts']],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user