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:
216
src/components/berths/berth-interests-tab.tsx
Normal file
216
src/components/berths/berth-interests-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user