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

@@ -1,14 +1,16 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { Search } from 'lucide-react';
import { Search, X } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -23,13 +25,19 @@ interface AuditEntry {
userId: string | null;
action: string;
entityType: string;
entityId: string;
entityId: string | null;
fieldChanged: string | null;
oldValue: Record<string, unknown> | null;
newValue: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
ipAddress: string | null;
createdAt: string;
actor: { id: string; email: string; name: string } | null;
}
interface AuditResponse {
data: AuditEntry[];
pagination: { nextCursor: { createdAt: string; id: string } | null };
}
const ACTION_COLORS: Record<string, string> = {
@@ -40,6 +48,8 @@ const ACTION_COLORS: Record<string, string> = {
restore: 'bg-teal-500',
login: 'bg-gray-500',
permission_denied: 'bg-red-800',
merge: 'bg-purple-500',
revert: 'bg-amber-500',
};
const ENTITY_TYPES = [
@@ -58,40 +68,96 @@ const ENTITY_TYPES = [
'webhook',
];
function useDebounced<T>(value: T, ms = 300): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export function AuditLogList() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [nextCursor, setNextCursor] = useState<{
createdAt: string;
id: string;
} | null>(null);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
const [actionFilter, setActionFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [loadingMore, setLoadingMore] = useState(false);
const fetchLogs = useCallback(async () => {
// Filter state — debounce text inputs.
const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState<string>('all');
const [action, setAction] = useState<string>('all');
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const debouncedSearch = useDebounced(search);
const debouncedUserId = useDebounced(userId);
const queryString = useMemo(() => {
const params = new URLSearchParams({ limit: '50' });
if (entityType !== 'all') params.set('entityType', entityType);
if (action !== 'all') params.set('action', action);
if (debouncedSearch) params.set('search', debouncedSearch);
if (debouncedUserId) params.set('userId', debouncedUserId);
if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString());
if (dateTo) {
const end = new Date(dateTo);
end.setHours(23, 59, 59, 999);
params.set('dateTo', end.toISOString());
}
return params.toString();
}, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
const fetchFirstPage = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
limit: '50',
});
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
if (actionFilter !== 'all') params.set('action', actionFilter);
if (search) params.set('search', search);
const res = await apiFetch<{
data: AuditEntry[];
pagination: { total: number };
}>(`/api/v1/admin/audit?${params}`);
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${queryString}`);
setEntries(res.data);
setTotal(res.pagination.total);
setNextCursor(res.pagination.nextCursor);
} finally {
setLoading(false);
}
}, [page, entityTypeFilter, actionFilter, search]);
}, [queryString]);
const loadMore = useCallback(async () => {
if (!nextCursor) return;
setLoadingMore(true);
try {
const params = new URLSearchParams(queryString);
params.set('cursorAt', nextCursor.createdAt);
params.set('cursorId', nextCursor.id);
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${params}`);
setEntries((prev) => [...prev, ...res.data]);
setNextCursor(res.pagination.nextCursor);
} finally {
setLoadingMore(false);
}
}, [queryString, nextCursor]);
useEffect(() => {
void fetchLogs();
}, [fetchLogs]);
void fetchFirstPage();
}, [fetchFirstPage]);
function clearFilters() {
setSearch('');
setEntityType('all');
setAction('all');
setUserId('');
setDateFrom('');
setDateTo('');
}
const hasActiveFilter =
Boolean(search) ||
entityType !== 'all' ||
action !== 'all' ||
Boolean(userId) ||
Boolean(dateFrom) ||
Boolean(dateTo);
const columns: ColumnDef<AuditEntry, unknown>[] = [
{
@@ -117,7 +183,7 @@ export function AuditLogList() {
{row.original.action}
</Badge>
),
size: 100,
size: 110,
},
{
accessorKey: 'entityType',
@@ -125,9 +191,11 @@ export function AuditLogList() {
cell: ({ row }) => (
<div>
<span className="font-medium capitalize">{row.original.entityType}</span>
<code className="ml-2 text-xs text-muted-foreground">
{row.original.entityId.slice(0, 8)}...
</code>
{row.original.entityId ? (
<code className="ml-2 text-xs text-muted-foreground">
{row.original.entityId.slice(0, 8)}
</code>
) : null}
</div>
),
},
@@ -150,108 +218,166 @@ export function AuditLogList() {
},
},
{
accessorKey: 'userId',
header: 'User',
cell: ({ row }) => (
<code className="text-xs">
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
</code>
),
size: 100,
id: 'actor',
header: 'Actor',
cell: ({ row }) => {
const { actor, userId: rawId } = row.original;
if (actor) {
return (
<div className="text-sm">
<div className="font-medium">{actor.name}</div>
<div className="text-xs text-muted-foreground">{actor.email}</div>
</div>
);
}
if (rawId) {
return <code className="text-xs">{rawId.slice(0, 8)}</code>;
}
return <span className="text-xs text-muted-foreground">system</span>;
},
size: 200,
},
];
return (
<div>
<PageHeader title="Audit Log" description={`${total} entries`} />
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
<Select
value={entityTypeFilter}
onValueChange={(v) => {
setEntityTypeFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={actionFilter}
onValueChange={(v) => {
setActionFilter(v);
setPage(1);
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Actions</SelectItem>
<SelectItem value="create">Create</SelectItem>
<SelectItem value="update">Update</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
<SelectItem value="archive">Archive</SelectItem>
<SelectItem value="restore">Restore</SelectItem>
<SelectItem value="permission_denied">Permission Denied</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={entries}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No audit log entries found.</p>
</div>
}
<PageHeader
title="Audit Log"
eyebrow="Admin"
description="Every state change in this port — fully searchable."
variant="gradient"
/>
{total > 50 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {Math.ceil(total / 50)}
</span>
<button
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
disabled={page >= Math.ceil(total / 50)}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
<div className="mt-4 flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label htmlFor="audit-search" className="text-xs">
Search
</Label>
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="audit-search"
className="pl-9"
placeholder="entity id, action, vendor…"
value={search}
onChange={(e) => setSearch(e.target.value)}
data-testid="audit-search"
/>
</div>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">Entity</Label>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36" data-testid="audit-entity">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All entities</SelectItem>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Action</Label>
<Select value={action} onValueChange={setAction}>
<SelectTrigger className="w-36" data-testid="audit-action">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All actions</SelectItem>
<SelectItem value="create">Create</SelectItem>
<SelectItem value="update">Update</SelectItem>
<SelectItem value="delete">Delete</SelectItem>
<SelectItem value="archive">Archive</SelectItem>
<SelectItem value="restore">Restore</SelectItem>
<SelectItem value="merge">Merge</SelectItem>
<SelectItem value="revert">Revert</SelectItem>
<SelectItem value="login">Login</SelectItem>
<SelectItem value="permission_denied">Permission denied</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-user" className="text-xs">
User id
</Label>
<Input
id="audit-user"
className="w-44"
placeholder="exact user id"
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-from" className="text-xs">
From
</Label>
<Input
id="audit-from"
type="date"
className="w-36"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audit-to" className="text-xs">
To
</Label>
<Input
id="audit-to"
type="date"
className="w-36"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{hasActiveFilter ? (
<Button variant="ghost" size="sm" onClick={clearFilters} className="ml-auto">
<X className="mr-1.5 h-3 w-3" />
Clear
</Button>
) : null}
</div>
<div className="mt-4">
<DataTable
columns={columns}
data={entries}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No audit log entries found.</p>
</div>
}
/>
</div>
{nextCursor ? (
<div className="mt-4 flex justify-center">
<Button
variant="outline"
size="sm"
disabled={loadingMore}
onClick={() => void loadMore()}
data-testid="audit-load-more"
>
{loadingMore ? 'Loading…' : 'Load more'}
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,290 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Eye, EyeOff, Loader2, XCircle } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { usePermissions } from '@/hooks/use-permissions';
import { apiFetch } from '@/lib/api/client';
type Provider = 'openai' | 'claude';
interface ConfigResp {
data: {
provider: Provider;
model: string;
hasApiKey: boolean;
useGlobal: boolean;
};
models: Record<Provider, string[]>;
}
type Scope = 'port' | 'global';
interface SettingsBlockProps {
scope: Scope;
title: string;
description: string;
/** Hide the "use global" checkbox on the global tab. */
showUseGlobal?: boolean;
}
function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlockProps) {
const queryClient = useQueryClient();
const queryKey = ['ocr-settings', scope];
const { data, isLoading } = useQuery<ConfigResp>({
queryKey,
queryFn: () => apiFetch<ConfigResp>(`/api/v1/admin/ocr-settings?scope=${scope}`),
});
const [provider, setProvider] = useState<Provider>('openai');
const [model, setModel] = useState<string>('gpt-4o-mini');
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null,
);
useEffect(() => {
if (!data?.data) return;
setProvider(data.data.provider);
setModel(data.data.model);
setUseGlobal(data.data.useGlobal);
}, [data?.data]);
const save = useMutation({
mutationFn: (clearApiKey?: boolean) =>
apiFetch('/api/v1/admin/ocr-settings', {
method: 'PUT',
body: {
scope,
provider,
model,
apiKey: apiKey.length > 0 ? apiKey : undefined,
clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal,
},
}),
onSuccess: () => {
setApiKey('');
queryClient.invalidateQueries({ queryKey });
},
});
const test = useMutation({
mutationFn: () =>
apiFetch<{ ok: boolean; reason?: string }>(`/api/v1/admin/ocr-settings/test`, {
method: 'POST',
body: { provider, model, apiKey },
}),
onSuccess: (res) =>
setTestStatus(res.ok ? { ok: true } : { ok: false, reason: res.reason ?? 'Unknown' }),
onError: (err: unknown) =>
setTestStatus({
ok: false,
reason: err instanceof Error ? err.message : 'Network error',
}),
});
const models = data?.models[provider] ?? [];
const hasKey = data?.data.hasApiKey ?? false;
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</CardHeader>
<CardContent className="space-y-4">
{showUseGlobal ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`useGlobal-${scope}`}
checked={useGlobal}
onCheckedChange={(v) => setUseGlobal(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`useGlobal-${scope}`} className="text-sm font-medium">
Use the global API key for this port
</Label>
<p className="text-xs text-muted-foreground">
When enabled, this port falls back to the system-wide OCR settings. Per-port
provider/model/key are ignored.
</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label>
<Select
value={provider}
onValueChange={(v) => {
const p = v as Provider;
setProvider(p);
setModel(data?.models[p][0] ?? '');
setTestStatus(null);
}}
>
<SelectTrigger id={`provider-${scope}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="claude">Claude (Anthropic)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`model-${scope}`}>Model</Label>
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`model-${scope}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor={`apiKey-${scope}`}>API key</Label>
<div className="flex gap-2">
<Input
id={`apiKey-${scope}`}
type={showKey ? 'text' : 'password'}
autoComplete="off"
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setTestStatus(null);
}}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? 'Hide key' : 'Show key'}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Stored encrypted at rest. Never re-displayed after saving.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => save.mutate(false)}
disabled={save.isPending}
data-testid={`save-${scope}`}
>
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
Save settings
</Button>
<Button
type="button"
variant="outline"
onClick={() => test.mutate()}
disabled={test.isPending || apiKey.length === 0}
>
{test.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
Test connection
</Button>
{hasKey ? (
<Button
type="button"
variant="ghost"
onClick={() => save.mutate(true)}
disabled={save.isPending}
className="text-destructive"
>
Clear stored key
</Button>
) : null}
{testStatus?.ok ? (
<span className="inline-flex items-center gap-1 text-sm text-green-700">
<CheckCircle2 className="h-4 w-4" />
Connection OK
</span>
) : null}
{testStatus && !testStatus.ok ? (
<span className="inline-flex items-center gap-1 text-sm text-destructive">
<XCircle className="h-4 w-4" />
{testStatus.reason}
</span>
) : null}
</div>
</CardContent>
</Card>
);
}
export function OcrSettingsForm() {
const { isSuperAdmin } = usePermissions();
return (
<div className="space-y-6">
<PageHeader
title="Receipt OCR"
eyebrow="Admin"
description="Configure the AI provider used to read receipts captured via the mobile scanner."
variant="gradient"
/>
<SettingsBlock
scope="port"
title="This port"
description="Provider and key used when staff at this port scan a receipt."
showUseGlobal
/>
{isSuperAdmin ? (
<SettingsBlock
scope="global"
title="Global default"
description="Used by any port that opted into the global key. Super-admin only."
/>
) : null}
</div>
);
}

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

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

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

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

View 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';

View 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']],
});
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { Bookmark } from 'lucide-react';
import type { InterestRow } from '@/components/interests/interest-columns';
interface BerthInterestsTabProps {
berthId: string;
}
type StageFilter = 'all' | 'active' | 'lost';
type SortMode = 'newest' | 'stage' | 'category';
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',
};
const STAGE_ORDER: Record<string, number> = {
open: 0,
details_sent: 1,
in_communication: 2,
visited: 3,
signed_eoi_nda: 4,
deposit_10pct: 5,
contract: 6,
completed: 7,
};
const CATEGORY_RANK: Record<string, number> = {
hot_lead: 0,
specific_qualified: 1,
general_interest: 2,
};
const CATEGORY_LABELS: Record<string, string> = {
hot_lead: 'Hot Lead',
specific_qualified: 'Specific Qualified',
general_interest: 'General Interest',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface ListResponse {
data: InterestRow[];
total: number;
}
export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [stage, setStage] = useState<StageFilter>('all');
const [sortMode, setSortMode] = useState<SortMode>('newest');
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['interests', 'by-berth', berthId],
queryFn: () => apiFetch<ListResponse>(`/api/v1/interests?berthId=${berthId}&limit=200`),
staleTime: 30_000,
});
useRealtimeInvalidation({
'interest:created': [['interests', 'by-berth', berthId]],
'interest:updated': [['interests', 'by-berth', berthId]],
'interest:stageChanged': [['interests', 'by-berth', berthId]],
'interest:archived': [['interests', 'by-berth', berthId]],
'interest:berthLinked': [['interests', 'by-berth', berthId]],
'interest:berthUnlinked': [['interests', 'by-berth', berthId]],
});
const rows = useMemo<InterestRow[]>(() => {
const all = data?.data ?? [];
const filtered = all.filter((i) => {
if (stage === 'active') return i.pipelineStage !== 'completed' && !i.archivedAt;
if (stage === 'lost') return Boolean(i.archivedAt);
return true;
});
const sorted = [...filtered].sort((a, b) => {
if (sortMode === 'stage') {
const sa = STAGE_ORDER[a.pipelineStage] ?? 99;
const sb = STAGE_ORDER[b.pipelineStage] ?? 99;
if (sa !== sb) return sb - sa; // furthest along first
}
if (sortMode === 'category') {
const ca = CATEGORY_RANK[a.leadCategory ?? ''] ?? 99;
const cb = CATEGORY_RANK[b.leadCategory ?? ''] ?? 99;
if (ca !== cb) return ca - cb; // hottest first
}
// Default + tiebreaker: newest first.
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return sorted;
}, [data?.data, stage, sortMode]);
if (isLoading) return <TableSkeleton />;
if ((data?.data ?? []).length === 0) {
return (
<EmptyState
icon={Bookmark}
title="No interests linked to this berth"
description="Interests will appear here when prospects express interest in this specific berth."
/>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="text-xs text-muted-foreground">
{rows.length} of {data?.total ?? 0} interest{(data?.total ?? 0) === 1 ? '' : 's'}
</div>
<div className="ml-auto flex items-center gap-2">
<Select value={stage} onValueChange={(v) => setStage(v as StageFilter)}>
<SelectTrigger className="h-8 w-[140px]" data-testid="berth-interests-filter">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
<SelectItem value="active">Active only</SelectItem>
<SelectItem value="lost">Lost / archived</SelectItem>
</SelectContent>
</Select>
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="h-8 w-[160px]" data-testid="berth-interests-sort">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="stage">Stage progress</SelectItem>
<SelectItem value="category">Lead category</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-card">
<table className="w-full text-sm" data-testid="berth-interests-table">
<thead className="bg-muted/40 text-left text-xs font-medium text-muted-foreground">
<tr>
<th className="px-3 py-2">Client</th>
<th className="px-3 py-2">Stage</th>
<th className="px-3 py-2">Category</th>
<th className="px-3 py-2">Source</th>
<th className="px-3 py-2">Last activity</th>
<th className="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{rows.map((i) => (
<tr
key={i.id}
className="border-t border-border last:border-b-0 hover:bg-gradient-brand-soft/40"
>
<td className="px-3 py-2 font-medium text-foreground">
<Link
href={`/${portSlug}/interests/${i.id}` as never}
className="hover:text-brand"
>
{i.clientName ?? '—'}
</Link>
</td>
<td className="px-3 py-2">
<Badge variant="secondary" className="font-normal">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '—'}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(i.createdAt).toLocaleDateString()}
</td>
<td className="px-3 py-2 text-right">
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
<Link href={`/${portSlug}/interests/${i.id}` as never}>Open</Link>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { type DetailTab } from '@/components/shared/detail-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TagBadge } from '@/components/shared/tag-badge';
import { BerthReservationsTab } from './berth-reservations-tab';
import { BerthInterestsTab } from './berth-interests-tab';
type BerthData = {
id: string;
@@ -181,7 +182,7 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] {
{
id: 'interests',
label: 'Interests',
content: <StubTab label="Interests" />,
content: <BerthInterestsTab berthId={berth.id} />,
},
{
id: 'reservations',

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

View File

@@ -35,6 +35,7 @@ interface HubDoc {
interface HubCounts {
all: number;
eoi_queue: number;
awaiting_them: number;
awaiting_me: number;
completed: number;
@@ -43,6 +44,7 @@ interface HubCounts {
const TAB_LABELS: Record<DocumentsHubTab, string> = {
all: 'All',
eoi_queue: 'EOI queue',
awaiting_them: 'Awaiting them',
awaiting_me: 'Awaiting me',
completed: 'Completed',
@@ -118,6 +120,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
const counts: HubCounts = countsResp?.data ?? {
all: 0,
eoi_queue: 0,
awaiting_them: 0,
awaiting_me: 0,
completed: 0,

View File

@@ -29,6 +29,9 @@ export interface ExpenseRow {
receiptFileIds: string[] | null;
archivedAt: string | null;
createdAt: string;
/** Set by the dedup engine when this expense looks like a duplicate of another. */
duplicateOf: string | null;
dedupScannedAt: string | null;
}
const PAYMENT_STATUS_COLORS: Record<string, string> = {
@@ -94,7 +97,8 @@ export function getExpenseColumns({
cell: ({ row }) =>
row.original.amountUsd ? (
<span className="text-sm text-muted-foreground tabular-nums">
${Number(row.original.amountUsd).toLocaleString('en-US', {
$
{Number(row.original.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
@@ -125,10 +129,7 @@ export function getExpenseColumns({
const status = (getValue() as string | null) ?? 'unpaid';
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
return (
<Badge
variant="outline"
className={`capitalize text-xs border ${colorClass}`}
>
<Badge variant="outline" className={`capitalize text-xs border ${colorClass}`}>
{status}
</Badge>
);
@@ -162,10 +163,7 @@ export function getExpenseColumns({
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>

View File

@@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from './expense-columns';
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
const PAYMENT_STATUS_COLORS: Record<string, string> = {
unpaid: 'bg-red-100 text-red-700 border-red-200',
@@ -52,9 +53,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
if (error || !data?.data) {
return (
<div className="p-6 text-center text-muted-foreground">
Failed to load expense details.
</div>
<div className="p-6 text-center text-muted-foreground">Failed to load expense details.</div>
);
}
@@ -64,6 +63,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
return (
<div className="space-y-6">
<ExpenseDuplicateBanner expense={expense} />
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">
@@ -107,10 +107,12 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
</p>
{expense.amountUsd && expense.currency !== 'USD' && (
<p className="text-sm text-muted-foreground mt-1">
${Number(expense.amountUsd).toLocaleString('en-US', {
$
{Number(expense.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} USD
})}{' '}
USD
</p>
)}
</CardContent>
@@ -121,10 +123,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
</CardHeader>
<CardContent>
<Badge
variant="outline"
className={`capitalize text-sm border ${statusColor}`}
>
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
{status}
</Badge>
</CardContent>
@@ -138,15 +137,11 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
<CardContent className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">
{expense.category?.replace(/_/g, ' ') ?? '—'}
</p>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">
{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}
</p>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Payer</span>

View File

@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import type { ExpenseRow } from './expense-columns';
interface Props {
expense: ExpenseRow;
}
export function ExpenseDuplicateBanner({ expense }: Props) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [resolving, setResolving] = useState(false);
// Fetch the candidate expense for context.
const { data: candidateResp } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', expense.duplicateOf],
queryFn: () => apiFetch(`/api/v1/expenses/${expense.duplicateOf}`),
enabled: Boolean(expense.duplicateOf),
staleTime: 30_000,
});
const candidate = candidateResp?.data;
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
};
const merge = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/expenses/${expense.id}/merge`, {
method: 'POST',
body: { targetId: expense.duplicateOf },
}),
onSuccess: () => {
invalidate();
setResolving(false);
},
});
const clear = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/expenses/${expense.id}/clear-duplicate`, { method: 'POST' }),
onSuccess: () => {
invalidate();
setResolving(false);
},
});
if (!expense.duplicateOf) return null;
const candidateLabel = candidate
? `${candidate.establishmentName ?? 'Unnamed expense'} · ${
candidate.amount
} ${candidate.currency} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}`
: 'a previously recorded expense';
return (
<div
data-testid="expense-duplicate-banner"
className={cn(
'flex flex-col gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900',
'sm:flex-row sm:items-center sm:justify-between',
)}
>
<div className="flex min-w-0 items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Looks like a duplicate</p>
<p className="mt-0.5 text-xs text-amber-800">
This expense matches{' '}
<Link
href={`/${portSlug}/expenses/${expense.duplicateOf}` as never}
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
>
{candidateLabel}
<ExternalLink className="h-3 w-3" aria-hidden />
</Link>
. Merge to consolidate, or mark as not a duplicate to keep them separate.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-amber-400 bg-white"
disabled={resolving || merge.isPending || clear.isPending}
onClick={() => {
setResolving(true);
merge.mutate();
}}
data-testid="expense-merge-btn"
>
Merge them
</Button>
<Button
variant="ghost"
size="sm"
disabled={resolving || merge.isPending || clear.isPending}
onClick={() => {
setResolving(true);
clear.mutate();
}}
data-testid="expense-not-duplicate-btn"
>
Not a duplicate
</Button>
</div>
</div>
);
}

View File

@@ -15,6 +15,8 @@ import {
FolderOpen,
Mail,
Bell,
Camera,
ShieldAlert,
Settings,
Shield,
Home,
@@ -69,6 +71,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true,
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/alerts`, label: 'Alerts', icon: ShieldAlert },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
@@ -105,6 +108,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/scan`, label: 'Scan receipt', icon: Camera },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
],
},

View File

@@ -19,6 +19,7 @@ import { PortSwitcher } from '@/components/layout/port-switcher';
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
import { CommandSearch } from '@/components/search/command-search';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { AlertBell } from '@/components/alerts/alert-bell';
import type { Port } from '@/lib/db/schema/ports';
interface TopbarProps {
@@ -87,6 +88,9 @@ export function Topbar({ ports, user }: TopbarProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* Phase B operational alerts — distinct from user notifications */}
<AlertBell />
{/* Notification bell — real-time via socket */}
<NotificationBell />

View File

@@ -0,0 +1,506 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useUIStore } from '@/stores/ui-store';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ParsedReceipt {
establishment: string | null;
date: string | null;
amount: number | null;
currency: string | null;
lineItems: Array<{ description: string; amount: number }>;
confidence: number;
}
type ScanState =
| { kind: 'idle' }
| { kind: 'processing' }
| {
kind: 'verify';
parsed: ParsedReceipt;
source: 'ai' | 'manual';
reason?: string;
providerError?: string;
}
| { kind: 'saving' }
| { kind: 'saved'; expenseId: string }
| { kind: 'error'; message: string };
interface ScanResp {
data: {
parsed: ParsedReceipt;
source: 'ai' | 'manual';
reason?: string;
provider?: string;
model?: string;
providerError?: string;
};
}
// ─── Form ─────────────────────────────────────────────────────────────────────
interface VerifyFormProps {
parsed: ParsedReceipt;
imagePreview: string;
imageFile: File;
source: 'ai' | 'manual';
reason?: string;
providerError?: string;
onSubmit: (input: {
establishmentName: string;
amount: string;
currency: string;
expenseDate: string;
category: string;
paymentMethod: string;
description: string;
file: File;
}) => void;
onRetake: () => void;
saving: boolean;
}
const TODAY = () => new Date().toISOString().slice(0, 10);
function VerifyForm({
parsed,
imagePreview,
imageFile,
source,
reason,
providerError,
onSubmit,
onRetake,
saving,
}: VerifyFormProps) {
const [establishmentName, setEstablishment] = useState(parsed.establishment ?? '');
const [amount, setAmount] = useState(parsed.amount != null ? String(parsed.amount) : '');
const [currency, setCurrency] = useState((parsed.currency ?? 'USD').toUpperCase());
const [expenseDate, setExpenseDate] = useState(parsed.date ?? TODAY());
const [category, setCategory] = useState<string>('other');
const [paymentMethod, setPaymentMethod] = useState<string>('credit_card');
const [description, setDescription] = useState('');
const lowConfidence = source === 'ai' && parsed.confidence < 0.6;
const noOcr = source === 'manual';
const banner = noOcr ? (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
{reason === 'no-ocr-configured' ? (
<>
<p className="font-medium">Manual entry mode</p>
<p className="text-xs mt-0.5">
No AI provider is configured for this port. Fill in the details below to save the
expense with the photo attached.
</p>
</>
) : (
<>
<p className="font-medium">We couldn&apos;t read the receipt automatically</p>
<p className="text-xs mt-0.5">
{providerError ? `Reason: ${providerError}.` : ''} Fill in the details below to save
the expense with the photo attached.
</p>
</>
)}
</div>
</div>
) : lowConfidence ? (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Low-confidence read please double-check the fields</p>
<p className="text-xs mt-0.5">
The AI returned a confidence of {Math.round(parsed.confidence * 100)}%.
</p>
</div>
</div>
) : (
<div className="flex items-start gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Receipt parsed confirm the fields and save</p>
<p className="text-xs mt-0.5">Confidence {Math.round(parsed.confidence * 100)}%.</p>
</div>
</div>
);
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit({
establishmentName,
amount,
currency,
expenseDate,
category,
paymentMethod,
description,
file: imageFile,
});
}}
>
{banner}
<div className="overflow-hidden rounded-lg border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview}
alt="Receipt preview"
className="block w-full max-h-[40vh] object-contain bg-muted"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="establishmentName">Vendor / establishment</Label>
<Input
id="establishmentName"
value={establishmentName}
onChange={(e) => setEstablishment(e.target.value)}
placeholder="e.g. Marina Fuel Station"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="expenseDate">Date</Label>
<Input
id="expenseDate"
type="date"
value={expenseDate}
onChange={(e) => setExpenseDate(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((c) => (
<SelectItem key={c} value={c} className="capitalize">
{c.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="paymentMethod">Payment method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger id="paymentMethod">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((p) => (
<SelectItem key={p} value={p} className="capitalize">
{p.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="description">Notes (optional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
disabled={saving || !amount}
className="h-12 text-base sm:flex-1"
data-testid="scan-save"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save expense
</Button>
<Button
type="button"
variant="outline"
onClick={onRetake}
disabled={saving}
className="h-12 text-base"
>
<RotateCcw className="mr-2 h-4 w-4" />
Retake
</Button>
</div>
</form>
);
}
// ─── Shell ────────────────────────────────────────────────────────────────────
export function ScanShell() {
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<ScanState>({ kind: 'idle' });
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Revoke blob URL on unmount.
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
};
}, [imagePreview]);
async function handleFile(file: File) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file));
setState({ kind: 'processing' });
try {
const fd = new FormData();
fd.append('file', file);
const portId = useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const res = await fetch('/api/v1/expenses/scan-receipt', {
method: 'POST',
body: fd,
credentials: 'include',
headers,
});
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
const body = (await res.json()) as ScanResp;
setState({
kind: 'verify',
parsed: body.data.parsed,
source: body.data.source,
reason: body.data.reason,
providerError: body.data.providerError,
});
} catch (err) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'Upload failed',
});
}
}
async function handleSubmit(input: {
establishmentName: string;
amount: string;
currency: string;
expenseDate: string;
category: string;
paymentMethod: string;
description: string;
file: File;
}) {
setState({ kind: 'saving' });
try {
// Upload the image (multipart — apiFetch wraps JSON, so use raw fetch).
const fd = new FormData();
fd.append('file', input.file);
fd.append('category', 'receipt');
const portId = useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const upRes = await fetch('/api/v1/files/upload', {
method: 'POST',
body: fd,
credentials: 'include',
headers,
});
if (!upRes.ok) throw new Error(`Upload failed: ${upRes.status}`);
const upJson = (await upRes.json()) as { data: { id: string } };
const expense = await apiFetch<{ data: { id: string } }>(`/api/v1/expenses`, {
method: 'POST',
body: {
establishmentName: input.establishmentName || undefined,
amount: input.amount,
currency: input.currency,
expenseDate: input.expenseDate,
category: input.category,
paymentMethod: input.paymentMethod,
description: input.description || undefined,
receiptFileIds: [upJson.data.id],
paymentStatus: 'unpaid',
},
});
setState({ kind: 'saved', expenseId: expense.data.id });
} catch (err) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'Save failed',
});
}
}
function reset() {
if (imagePreview) {
URL.revokeObjectURL(imagePreview);
setImagePreview(null);
}
setState({ kind: 'idle' });
if (fileRef.current) fileRef.current.value = '';
}
return (
<main className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 sm:py-10">
<header className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Scan a receipt</h1>
{state.kind !== 'idle' ? (
<Button variant="ghost" size="sm" onClick={reset}>
Start over
</Button>
) : null}
</header>
{state.kind === 'idle' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-border bg-muted/20 p-8 text-center">
<Camera className="h-12 w-12 text-muted-foreground/60" aria-hidden />
<div>
<p className="text-base font-medium">Tap to capture a receipt</p>
<p className="mt-1 text-xs text-muted-foreground">
Use your camera or pick an image from your library. We&apos;ll read it and pre-fill
the form for you to confirm.
</p>
</div>
<Button
type="button"
className="h-12 px-6 text-base"
onClick={() => fileRef.current?.click()}
data-testid="scan-capture"
>
<Camera className="mr-2 h-5 w-5" />
Capture receipt
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
</section>
) : null}
{state.kind === 'processing' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
<Loader2 className="h-10 w-10 animate-spin text-brand" />
<p className="text-sm text-muted-foreground">Reading receipt</p>
</section>
) : null}
{state.kind === 'verify' && imagePreview ? (
<VerifyForm
parsed={state.parsed}
imagePreview={imagePreview}
imageFile={fileRef.current?.files?.[0] as File}
source={state.source}
reason={state.reason}
providerError={state.providerError}
onSubmit={handleSubmit}
onRetake={reset}
saving={false}
/>
) : null}
{state.kind === 'saving' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
<Loader2 className="h-10 w-10 animate-spin text-brand" />
<p className="text-sm text-muted-foreground">Saving expense</p>
</section>
) : null}
{state.kind === 'saved' ? (
<section className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 p-8 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
<p className="text-base font-semibold text-emerald-900">Expense saved</p>
<div className="flex gap-2">
<Button onClick={reset} variant="outline" data-testid="scan-another">
Scan another
</Button>
<Button
onClick={() => router.push(`/${portSlug}/expenses/${state.expenseId}` as never)}
>
View expense
</Button>
</div>
</section>
) : null}
{state.kind === 'error' ? (
<section
className={cn(
'flex flex-col items-center gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-6 text-center',
)}
>
<AlertTriangle className="h-10 w-10 text-destructive" />
<p className="text-base font-medium text-destructive">{state.message}</p>
<Button onClick={reset} variant="outline">
Try again
</Button>
</section>
) : null}
</main>
);
}