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:
Matt Ciaccio
2026-04-28 17:21:55 +02:00
parent 2fa70f4582
commit f52d21df83
63 changed files with 4459 additions and 206 deletions

View File

@@ -0,0 +1,134 @@
'use client';
import { useRef, type ReactNode } from 'react';
import { MoreHorizontal, Download, Image as ImageIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
interface ChartCardProps {
title: string;
description?: string;
/** Filename stem used for both CSV + PNG exports (no extension). */
exportFilename: string;
/** Returns CSV content for the current chart data, or null when nothing to export. */
toCsv?: () => string | null;
children: ReactNode;
className?: string;
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function exportContainerAsPng(container: HTMLElement, filename: string) {
const svg = container.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true) as SVGSVGElement;
const { width, height } = svg.getBoundingClientRect();
clone.setAttribute('width', String(width));
clone.setAttribute('height', String(height));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Failed to load chart for export'));
img.src = url;
});
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio ?? 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
return;
}
ctx.scale(dpr, dpr);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (blob) downloadBlob(blob, filename);
}, 'image/png');
}
export function ChartCard({
title,
description,
exportFilename,
toCsv,
children,
className,
}: ChartCardProps) {
const containerRef = useRef<HTMLDivElement>(null);
function onDownloadCsv() {
const csv = toCsv?.();
if (!csv) return;
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `${exportFilename}.csv`);
}
function onDownloadPng() {
if (containerRef.current) {
void exportContainerAsPng(containerRef.current, `${exportFilename}.png`);
}
}
return (
<Card className={cn('h-full', className)}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Chart options"
data-testid="chart-menu"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{toCsv ? (
<DropdownMenuItem onSelect={onDownloadCsv}>
<Download className="mr-2 h-4 w-4" />
Download CSV
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={onDownloadPng}>
<ImageIcon className="mr-2 h-4 w-4" />
Download PNG
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent>
<div ref={containerRef}>{children}</div>
</CardContent>
</Card>
);
}

View File

@@ -1,22 +1,40 @@
'use client';
import { useState } from 'react';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { PageHeader } from '@/components/shared/page-header';
import { KpiCardsWithBoundary } from './kpi-cards';
import { PipelineChart } from './pipeline-chart';
import { RevenueForecast } from './revenue-forecast';
import { ActivityFeed } from './activity-feed';
import { DateRangePicker } from './date-range-picker';
import { PipelineFunnelChart } from './pipeline-funnel-chart';
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { LeadSourceChart } from './lead-source-chart';
import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail';
import type { DateRange } from '@/lib/services/analytics.service';
const RANGE_LABELS: Record<DateRange, string> = {
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
};
export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d');
useRealtimeInvalidation({
'interest:stageChanged': [
['dashboard', 'pipeline'],
['dashboard', 'forecast'],
['analytics', 'pipeline_funnel', range],
['analytics', 'lead_source_attribution', range],
['dashboard', 'kpis'],
],
'client:created': [['dashboard', 'kpis']],
'berth:statusChanged': [
['analytics', 'occupancy_timeline', range],
['dashboard', 'kpis'],
['dashboard', 'forecast'],
],
});
@@ -26,26 +44,37 @@ export function DashboardShell() {
title="Dashboard"
eyebrow="Overview"
description="Live snapshot of your marina activity"
kpiLine={<span>Last 30 days</span>}
kpiLine={<span>{RANGE_LABELS[range]}</span>}
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
/>
{/* Row 1: KPI cards */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<KpiCardsWithBoundary />
</div>
{/* Row 2: Pipeline chart + Revenue forecast */}
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<div className="lg:col-span-2">
<PipelineChart />
</div>
<div className="lg:col-span-1">
<RevenueForecast />
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
<WidgetErrorBoundary>
<PipelineFunnelChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<OccupancyTimelineChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<RevenueBreakdownChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<LeadSourceChart range={range} />
</WidgetErrorBoundary>
</div>
<aside className="min-w-0">
<WidgetErrorBoundary>
<AlertRail />
</WidgetErrorBoundary>
</aside>
</div>
{/* Row 3: Activity feed */}
<ActivityFeed />
</div>
);

View File

@@ -0,0 +1,55 @@
'use client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { DateRange } from '@/lib/services/analytics.service';
interface DateRangePickerProps {
value: DateRange;
onChange: (next: DateRange) => void;
className?: string;
}
const OPTIONS: Array<{ value: DateRange; label: string }> = [
{ value: 'today', label: 'Today' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: '90d', label: '90d' },
];
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
return (
<div
role="tablist"
aria-label="Date range"
className={cn(
'inline-flex items-center rounded-lg border border-border bg-muted/40 p-0.5 shadow-xs',
className,
)}
>
{OPTIONS.map((opt) => {
const active = opt.value === value;
return (
<Button
key={opt.value}
type="button"
role="tab"
aria-selected={active}
variant="ghost"
size="sm"
onClick={() => onChange(opt.value)}
className={cn(
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
data-testid={`range-${opt.value}`}
>
{opt.label}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useLeadSource } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
interface Props {
range: DateRange;
}
const COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
];
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
referral: 'Referral',
manual: 'Manual',
social: 'Social',
unspecified: 'Unspecified',
};
export function LeadSourceChart({ range }: Props) {
const { data, isLoading } = useLeadSource(range);
const slices = data?.slices ?? [];
function toCsv(): string | null {
if (!slices.length) return null;
const header = 'source,count';
const rows = slices.map((s) => `${s.source},${s.count}`);
return [header, ...rows].join('\n');
}
const chartData = slices.map((s) => ({
name: SOURCE_LABELS[s.source] ?? s.source,
value: s.count,
}));
return (
<ChartCard
title="Lead Source Attribution"
description="Where new interests came from"
exportFilename={`lead-source-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : !slices.length ? (
<EmptyState title="No interests in range" />
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
innerRadius={50}
paddingAngle={2}
>
{chartData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useOccupancy } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
interface Props {
range: DateRange;
}
function shortDate(iso: string) {
const d = new Date(`${iso}T00:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function OccupancyTimelineChart({ range }: Props) {
const { data, isLoading } = useOccupancy(range);
const points = data?.points ?? [];
const noBerths = points.length > 0 && points[0]?.total === 0;
function toCsv(): string | null {
if (!points.length) return null;
const header = 'date,occupied,total,occupancy_pct';
const rows = points.map((p) => `${p.date},${p.occupied},${p.total},${p.occupancyPct}`);
return [header, ...rows].join('\n');
}
return (
<ChartCard
title="Occupancy Timeline"
description="Daily berth occupancy across the range"
exportFilename={`occupancy-timeline-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : noBerths ? (
<EmptyState title="No berths configured" description="Add berths to see occupancy." />
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart
data={points.map((p) => ({ ...p, label: shortDate(p.date) }))}
margin={{ top: 8, right: 8, left: -16, bottom: 8 }}
>
<defs>
<linearGradient id="occupancyGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-2))" stopOpacity={0.4} />
<stop offset="100%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
minTickGap={20}
/>
<YAxis
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
domain={[0, 100]}
tickFormatter={(v: number) => `${v}%`}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, _name, item) => {
const p = item?.payload as { occupied?: number; total?: number } | undefined;
return [`${value}% (${p?.occupied ?? 0}/${p?.total ?? 0})`, 'Occupancy'];
}}
/>
<Area
type="monotone"
dataKey="occupancyPct"
stroke="hsl(var(--chart-2))"
strokeWidth={2}
fill="url(#occupancyGradient)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useFunnel } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
interface Props {
range: DateRange;
}
export function PipelineFunnelChart({ range }: Props) {
const { data, isLoading } = useFunnel(range);
const stages = data?.stages ?? [];
const chartData = stages.map((s) => ({
stage: STAGE_LABELS[s.stage] ?? s.stage,
count: s.count,
conversionPct: s.conversionPct,
}));
const allZero = stages.every((s) => s.count === 0);
function toCsv(): string | null {
if (!stages.length) return null;
const header = 'stage,count,conversion_pct';
const rows = stages.map((s) => `${s.stage},${s.count},${s.conversionPct}`);
return [header, ...rows].join('\n');
}
return (
<ChartCard
title="Pipeline Funnel"
description="Interests by stage with conversion rate vs. open"
exportFilename={`pipeline-funnel-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : allZero ? (
<EmptyState title="No interests in range" description="Try a longer date range." />
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="stage"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
angle={-40}
textAnchor="end"
interval={0}
/>
<YAxis
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, _name, item) => {
const pct = (item?.payload as { conversionPct?: number } | undefined)
?.conversionPct;
return [`${value} (${pct ?? 0}%)`, 'Count'];
}}
/>
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useRevenue } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
interface Props {
range: DateRange;
}
const STATUS_LABELS: Record<string, string> = {
draft: 'Draft',
sent: 'Sent',
paid: 'Paid',
overdue: 'Overdue',
cancelled: 'Cancelled',
};
export function RevenueBreakdownChart({ range }: Props) {
const { data, isLoading } = useRevenue(range);
const bars = data?.bars ?? [];
function toCsv(): string | null {
if (!bars.length) return null;
const header = 'status,currency,amount';
const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`);
return [header, ...rows].join('\n');
}
const chartData = bars.map((b) => ({
label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`,
amount: b.amount,
currency: b.currency,
}));
return (
<ChartCard
title="Revenue Breakdown"
description="Invoice totals grouped by status and currency"
exportFilename={`revenue-breakdown-${range}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : !bars.length ? (
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
angle={-30}
textAnchor="end"
interval={0}
/>
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, _name, item) => {
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? '';
const num = typeof value === 'number' ? value : Number(value);
return [`${num.toLocaleString()} ${c}`, 'Amount'];
}}
/>
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type {
DateRange,
LeadSourceAttributionData,
MetricBase,
OccupancyTimelineData,
PipelineFunnelData,
RevenueBreakdownData,
} from '@/lib/services/analytics.service';
interface MetricResponse<T> {
metric: MetricBase;
range: DateRange;
data: T;
}
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
return useQuery<T>({
queryKey: ['analytics', metric, range],
queryFn: async () => {
const res = await apiFetch<MetricResponse<T>>(
`/api/v1/analytics?metric=${metric}&range=${range}`,
);
return res.data;
},
staleTime: 60_000,
retry: 2,
});
}
export const useFunnel = (range: DateRange) =>
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
export const useOccupancy = (range: DateRange) =>
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
export const useRevenue = (range: DateRange) =>
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
export const useLeadSource = (range: DateRange) =>
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);