feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -0,0 +1,68 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { TrendingUp } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface KpiResponse {
totalClients: number;
activeInterests: number;
pipelineValueUsd: number;
occupancyRate: number;
}
/**
* Compact rail-sized KPI tile — single number, label, and a click-
* through to the interests pipeline. Reuses the existing dashboard KPIs
* endpoint so we don't pay an extra round-trip.
*/
export function ActiveDealsTile() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<KpiResponse>({
queryKey: ['dashboard', 'kpis'],
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
staleTime: 60_000,
});
return (
<Card>
{/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships
with `pt-0` (it assumes a CardHeader sits above). Without these
overrides the tile content snaps to the top edge of the card. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
<TrendingUp className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active deals
</p>
{isLoading ? (
<Skeleton className="mt-1 h-7 w-12" />
) : (
<p className="text-2xl font-bold leading-tight text-foreground">
{data?.activeInterests ?? 0}
</p>
)}
</div>
<Link
// Next typedRoutes can't infer dynamic-segment routes from a template
// literal — cast through unknown rather than `any` so the lint rule
// is satisfied while the runtime href is still correct.
href={`/${portSlug}/interests` as unknown as Route}
className="text-xs font-medium text-primary hover:underline"
>
View
</Link>
</CardContent>
</Card>
);
}

View File

@@ -14,11 +14,103 @@ interface ActivityItem {
action: string;
entityType: string;
entityId: string | null;
/** Server-resolved human label (client name, yacht name, …) when the
* underlying entity still exists. Falls back to the id prefix in the UI. */
label: string | null;
userId: string | null;
fieldChanged: string | null;
oldValue: unknown;
newValue: unknown;
metadata: Record<string, unknown> | null;
createdAt: string;
}
/** camelCase / snake_case field name → "Title Case" so the audit log
* reads naturally ("fullName" → "Full Name", "phone_number" → "Phone
* Number"). Single-word fields stay capitalized. */
function humanizeFieldName(name: string): string {
return name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Render a JSON-ish value as a short, single-line preview. Strings come
* through as-is; objects flatten to "k: v, k: v"; arrays compress to a
* count; nulls / empty render as em-dash. */
function shortValue(value: unknown): string {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return '—';
return entries
.slice(0, 3)
.map(([k, v]) => `${humanizeFieldName(k)}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(', ');
}
return String(value);
}
/** Build a "Field: old → new" diff string for the activity row's second
* line. Returns null when there's nothing useful to show.
*
* Audit logs for updates store the per-field diff inside `oldValue` as
* `{ field: { old, new }, … }` (see entity-diff.ts), so that's the
* shape we pattern-match first. Falls back to a fieldChanged/old→new
* pair when those are present, and finally to a key-by-key compare of
* two flat objects in `oldValue` vs `newValue`. */
function buildDiffLine(item: ActivityItem): string | null {
// Shape A: oldValue = { field: { old, new }, … }
if (
item.action === 'update' &&
item.oldValue &&
typeof item.oldValue === 'object' &&
!Array.isArray(item.oldValue)
) {
const diffMap = item.oldValue as Record<string, unknown>;
const entries = Object.entries(diffMap).filter(([, v]) => {
return v && typeof v === 'object' && 'old' in (v as object) && 'new' in (v as object);
});
if (entries.length > 0) {
return entries
.slice(0, 2)
.map(([field, v]) => {
const { old, new: nextValue } = v as { old: unknown; new: unknown };
return `${humanizeFieldName(field)}: ${shortValue(old)}${shortValue(nextValue)}`;
})
.join(' · ');
}
}
// Shape B: single-field change with explicit columns.
if (item.fieldChanged) {
return `${humanizeFieldName(item.fieldChanged)}: ${shortValue(item.oldValue)}${shortValue(item.newValue)}`;
}
// Shape C: flat oldValue vs flat newValue.
if (
item.action === 'update' &&
item.oldValue &&
typeof item.oldValue === 'object' &&
item.newValue &&
typeof item.newValue === 'object'
) {
const oldObj = item.oldValue as Record<string, unknown>;
const newObj = item.newValue as Record<string, unknown>;
const keys = Object.keys(oldObj).filter((k) => k in newObj);
if (keys.length === 0) return null;
return keys
.slice(0, 2)
.map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k])}${shortValue(newObj[k])}`)
.join(' · ');
}
return null;
}
const ACTION_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
create: 'default',
update: 'secondary',
@@ -63,27 +155,49 @@ function ActivityFeedInner() {
</p>
) : (
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
{items.map((item) => (
<div
key={item.id}
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
>
<ActionBadge action={item.action} />
<div className="min-w-0 flex-1">
<p className="truncate text-foreground">
<span className="font-medium capitalize">{item.entityType}</span>
{item.entityId && (
<span className="ml-1 text-muted-foreground font-mono text-xs">
{item.entityId.slice(0, 8)}
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</p>
{items.map((item) => {
const diffLine = buildDiffLine(item);
return (
<div
key={item.id}
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
>
<ActionBadge action={item.action} />
<div className="min-w-0 flex-1">
<p className="truncate text-foreground">
{item.label ? (
<>
<span className="font-medium">{item.label}</span>
<span className="ml-1.5 text-muted-foreground text-xs capitalize">
{item.entityType}
</span>
</>
) : (
<>
<span className="font-medium capitalize">{item.entityType}</span>
{item.entityId && (
<span className="ml-1 text-muted-foreground font-mono text-xs">
{item.entityId.slice(0, 8)}
</span>
)}
</>
)}
</p>
{diffLine ? (
<p
className="truncate text-xs text-muted-foreground mt-0.5"
title={diffLine}
>
{diffLine}
</p>
) : null}
<p className="text-[11px] text-muted-foreground/80 mt-0.5">
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</CardContent>

View File

@@ -0,0 +1,101 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface BerthStatusResponse {
data: {
total: number;
available: number;
underOffer: number;
sold: number;
maintenance: number;
};
}
// Brand-aligned palette. Order matches the legend reading order
// (positive → in-progress → closed → exception).
const SEGMENTS = [
{ key: 'available', label: 'Available', color: 'hsl(213 55% 56%)' },
{ key: 'underOffer', label: 'Under offer', color: 'hsl(38 92% 50%)' },
{ key: 'sold', label: 'Sold', color: 'hsl(142 70% 40%)' },
{ key: 'maintenance', label: 'Maintenance', color: 'hsl(228 10% 60%)' },
] as const;
/**
* Donut visualisation of the port's berth status mix. Sized to fit a
* single chart column (~360px wide) with a generous legend; degrades
* cleanly when a status has zero berths (segment is omitted, legend
* still hints at its absence).
*/
export function BerthStatusChart() {
const { data, isLoading } = useQuery<BerthStatusResponse>({
queryKey: ['dashboard', 'berth_status'],
queryFn: () => apiFetch<BerthStatusResponse>('/api/v1/dashboard/berth-status'),
staleTime: 60_000,
});
const stats = data?.data;
const chartData = stats
? SEGMENTS.map((s) => ({ ...s, value: stats[s.key] })).filter((s) => s.value > 0)
: [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Berth status</CardTitle>
<CardDescription>
{stats
? `${stats.sold} sold · ${stats.underOffer} under offer · ${stats.available} available`
: 'Distribution across the port'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[240px] w-full" />
) : chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">No berths yet.</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={85}
paddingAngle={2}
>
{chartData.map((d) => (
<Cell key={d.key} fill={d.color} />
))}
</Pie>
<Tooltip
formatter={(value, _name, payload) => {
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
const total = stats?.total ?? 0;
const pct = total > 0 ? Math.round((numeric / total) * 100) : 0;
const label = (payload as { payload?: { label?: string } } | undefined)
?.payload?.label;
return [`${numeric} (${pct}%)`, label ?? ''];
}}
/>
<Legend
verticalAlign="bottom"
height={36}
iconType="circle"
wrapperStyle={{ fontSize: 12 }}
/>
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -24,14 +24,19 @@ interface ChartCardProps {
className?: string;
}
function downloadBlob(blob: Blob, filename: string) {
/**
* Match the pattern used elsewhere in the codebase (see
* `src/app/(dashboard)/[portSlug]/expenses/page.tsx`, `client-files-tab.tsx`,
* `backup-admin-panel.tsx`). All four reduce to the same dead-simple shape
* and they all work — Chrome honours the `download` attribute and the
* file lands with the right name.
*/
function triggerBlobDownload(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);
}
@@ -44,31 +49,28 @@ async function exportContainerAsPng(container: HTMLElement, filename: string) {
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 svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
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;
img.src = svgDataUrl;
});
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;
}
if (!ctx) 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');
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/png'),
);
if (!blob) return;
triggerBlobDownload(blob, filename);
}
export function ChartCard({
@@ -84,7 +86,10 @@ export function ChartCard({
function onDownloadCsv() {
const csv = toCsv?.();
if (!csv) return;
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `${exportFilename}.csv`);
triggerBlobDownload(
new Blob([csv], { type: 'text/csv;charset=utf-8' }),
`${exportFilename}.csv`,
);
}
function onDownloadPng() {

View File

@@ -0,0 +1,129 @@
'use client';
import { useState } from 'react';
import { LayoutGrid } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
/**
* Modal widget picker for the dashboard header. Replaced the original
* dropdown menu because 13 widgets + 3 footer buttons made the dropdown
* cramped and hid the descriptions reps need to know what each card
* actually shows.
*
* Backed by the same `useDashboardWidgets` hook that drives the
* Settings card — toggles update both surfaces optimistically.
*/
export function CustomizeWidgetsMenu() {
const [open, setOpen] = useState(false);
const { allWidgets, visibility, setVisible, setAll, resetToDefaults, isSaving } =
useDashboardWidgets();
const visibleCount = Object.values(visibility).filter(Boolean).length;
const allVisible = visibleCount === allWidgets.length;
const allHidden = visibleCount === 0;
// Reset is a no-op when state already matches the registry defaults —
// disable in that case to avoid pointless API round-trips.
const matchesDefaults = allWidgets.every(
(w) => (visibility[w.id] ?? false) === w.defaultVisible,
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<LayoutGrid className="h-4 w-4" />
Customize
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Customize dashboard</DialogTitle>
<DialogDescription>
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty
space the layout reflows to fill the available width.
</DialogDescription>
</DialogHeader>
{/* Toggle list. Capped at ~60vh with internal scroll so the modal
doesn't push the action footer off-screen on shorter viewports. */}
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
<div className="space-y-1 py-1">
{allWidgets.map((w) => (
<label
key={w.id}
className="flex cursor-pointer items-start justify-between gap-4 rounded-md px-3 py-2.5 hover:bg-accent/40"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{w.label}</div>
<p className="text-xs text-muted-foreground">{w.description}</p>
</div>
<Switch
aria-label={`Show ${w.label}`}
checked={visibility[w.id] ?? false}
disabled={isSaving}
onCheckedChange={(checked) => setVisible(w.id, checked)}
className="mt-0.5 shrink-0"
/>
</label>
))}
</div>
</div>
{/* Footer: stacks vertically on mobile (counter row, secondary
buttons row, full-width primary "Done") so no button gets
orphaned beneath the others. Reverts to single inline row at
sm+ where there's space. */}
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="text-xs text-muted-foreground sm:order-first">
{visibleCount} of {allWidgets.length} visible
</span>
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<Button
variant="ghost"
size="sm"
disabled={matchesDefaults || isSaving}
onClick={resetToDefaults}
>
Reset to defaults
</Button>
<Button
variant="outline"
size="sm"
disabled={allHidden || isSaving}
onClick={() => setAll(false)}
>
Hide all
</Button>
<Button
variant="outline"
size="sm"
disabled={allVisible || isSaving}
onClick={() => setAll(true)}
>
Show all
</Button>
<Button
size="sm"
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
>
Done
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,19 +4,13 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { usePortContext } from '@/providers/port-provider';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
import { apiFetch } from '@/lib/api/client';
import { PageHeader } from '@/components/shared/page-header';
import { ActivityFeed } from './activity-feed';
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
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 { MyRemindersRail } from './my-reminders-rail';
import { WebsiteGlanceTile } from './website-glance-tile';
import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail';
import type { DashboardWidget } from './widget-registry';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
@@ -43,8 +37,10 @@ function rangeLabel(range: DateRange): string {
interface MeData {
data?: {
firstName?: string | null;
displayName?: string | null;
profile?: {
firstName?: string | null;
displayName?: string | null;
};
};
}
@@ -58,8 +54,14 @@ function timeOfDayGreeting(): string {
export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d');
const { currentPort } = usePortContext();
const portName = currentPort?.name ?? 'this port';
const { visibleWidgets } = useDashboardWidgets();
// Bucket once so the JSX stays readable. Registry order is preserved
// inside each bucket, so reordering the registry reorders the render.
const charts = visibleWidgets.filter((w) => w.group === 'chart');
const rails = visibleWidgets.filter((w) => w.group === 'rail');
const feed = visibleWidgets.filter((w) => w.group === 'feed');
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
// useTablePreferences elsewhere — usually a cache hit, so no extra
@@ -70,7 +72,7 @@ export function DashboardShell() {
queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }),
staleTime: 5 * 60_000,
});
const firstName = me.data?.data?.firstName?.trim();
const firstName = me.data?.data?.profile?.firstName?.trim();
// Time-aware greeting line, falls back to a generic "Welcome back" when
// we don't know the user's first name yet (e.g. profile not filled out).
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
@@ -96,51 +98,103 @@ export function DashboardShell() {
return (
<div className="space-y-6">
{/* Mobile-only greeting strip. The shared PageHeader is hidden
below `sm` (its title is normally duplicated by the topbar),
so we render the welcome message inline here for mobile —
keeps the personalized touch from desktop without polluting
the topbar (which stays "Dashboard" for wayfinding). */}
<div className="sm:hidden">
<p className="text-xs font-semibold uppercase tracking-wide text-brand">Dashboard</p>
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
</div>
<PageHeader
title={greeting}
eyebrow="Dashboard"
description={`Live snapshot of ${portName} activity`}
kpiLine={<span>{rangeLabel(range)}</span>}
// The date-range subtitle only means something when at least
// one widget is on the page to consume the range; if everything
// is hidden it just reads as an orphaned line.
kpiLine={visibleWidgets.length > 0 ? <span>{rangeLabel(range)}</span> : undefined}
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={setRange} />
<CustomizeWidgetsMenu />
</div>
}
/>
{/* `items-start` is critical: without it, the right-column aside is
{/* Charts + rails sit side-by-side at xl+. Each side is an auto-fit
grid, so hiding a card causes the remaining ones to widen.
`items-start` is critical: without it, the right-column aside is
stretched to match the chart column's row height, which forces
MyRemindersRail (or any other child with `h-full`) to push later
children out of the aside's box and into the rows below where
ActivityFeed renders. */}
<div className="grid gap-4 grid-cols-1 items-start 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>
children out of the aside's box. */}
{/* Charts + rails. Layout adapts to which regions have content so
we never leave a 320px stripe of dead space when only one side
is populated:
both → main 1fr column + 320px rail (the original layout)
charts only → single full-width auto-fit chart grid
rails only → rails widen into an auto-fit grid (no fixed 320)
neither → nothing renders
The chart grid uses `minmax(360px, 1fr)` so a lone chart fills
the row; the rails-only grid uses a slightly tighter `280px`
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
instead of stretching to 600px+ each. */}
{charts.length > 0 && rails.length > 0 ? (
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
<aside className="min-w-0 space-y-4">
{rails.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</aside>
</div>
<aside className="min-w-0 space-y-4">
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
isn't configured for this port. */}
<WidgetErrorBoundary>
<WebsiteGlanceTile />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<MyRemindersRail />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<AlertRail />
</WidgetErrorBoundary>
</aside>
</div>
) : charts.length > 0 ? (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
) : rails.length > 0 ? (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
{rails.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
) : null}
<ActivityFeed />
{feed.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
</div>
);
}
/**
* Placeholder shown when the rep has hidden every widget. Without this,
* the dashboard collapses to just the gradient header strip and looks
* like a broken page — this hints at the "Customize" button to bring
* widgets back.
*/
function EmptyDashboardHint() {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">No widgets on your dashboard yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Click <span className="font-medium text-foreground">Customize</span> above to pick which
analytics cards appear here.
</p>
</div>
);
}
function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) {
return <WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>;
}

View File

@@ -126,7 +126,15 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
{isCustom ? formatCustom(value) : 'Custom'}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[260px] p-3">
<PopoverContent
align="end"
// min() caps the popover at "viewport minus 16px" on narrow
// phones so it never overflows; otherwise sits at a compact
// 260px. Date inputs inside use w-auto so iOS's intrinsic
// date-input width (which ignores parent constraints) sizes
// to its own content rather than overflowing.
className="w-[min(260px,calc(100vw-1rem))] p-3"
>
<div className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Custom range
@@ -141,7 +149,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
empty result, and not understand why. */
max={draftTo && draftTo < today ? draftTo : today}
onChange={(e) => setDraftFrom(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<label className="block text-xs">
@@ -152,7 +160,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
min={draftFrom || undefined}
max={today}
onChange={(e) => setDraftTo(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<div className="flex items-center justify-end gap-2 pt-1">

View File

@@ -0,0 +1,108 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Flame } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface HotDeal {
id: string;
stage: string;
clientName: string;
mooringNumber: string | null;
lastContact: string | null;
}
interface HotDealsResponse {
data: HotDeal[];
}
const STAGE_LABELS: Record<string, string> = {
contract_signed: 'Contract Signed',
contract_sent: 'Contract Sent',
deposit_10: 'Deposit 10%',
eoi_signed: 'EOI Signed',
eoi_sent: 'EOI Sent',
in_comms: 'In Comms',
details_sent: 'Details Sent',
open: 'Open',
completed: 'Completed',
};
/**
* Top 5 in-flight interests closest to closing. Ranked server-side by
* pipeline stage (the further along, the closer to signing) with most-
* recent activity as a tiebreaker. Gives reps a "what should I be
* chasing this week" view without opening the full pipeline board.
*/
export function HotDealsCard() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<HotDealsResponse>({
queryKey: ['dashboard', 'hot_deals'],
queryFn: () => apiFetch<HotDealsResponse>('/api/v1/dashboard/hot-deals'),
staleTime: 60_000,
});
const deals = data?.data ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-1.5 text-base">
<Flame className="size-4 text-orange-500" aria-hidden />
Hot deals
</CardTitle>
<CardDescription>Active interests closest to closing.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : deals.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No active deals to chase. New leads will surface here once they advance past Open.
</p>
) : (
<ul className="space-y-1">
{deals.map((d) => (
<li key={d.id}>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/interests/${d.id}` as any}
className="-mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-accent/60"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{d.clientName}</p>
<p className="text-xs text-muted-foreground">
{d.mooringNumber ? `Berth ${d.mooringNumber}` : 'No berth linked'}
{d.lastContact ? (
<>
{' · '}
last touched {formatDistanceToNow(new Date(d.lastContact))} ago
</>
) : null}
</p>
</div>
<Badge variant="secondary" className="shrink-0 text-[10px]">
{STAGE_LABELS[d.stage] ?? d.stage}
</Badge>
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,8 @@ 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';
import { rangeToSlug } from '@/lib/analytics/range';
import { SOURCE_LABELS as CANONICAL_SOURCE_LABELS } from '@/lib/constants';
interface Props {
range: DateRange;
@@ -20,10 +22,11 @@ const COLORS = [
'hsl(var(--chart-5))',
];
// Extend the canonical source labels with the analytics-specific buckets the
// API returns (`unspecified` for null sources, legacy `social`). Renames to the
// canonical set in /lib/constants stay in sync via the spread.
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
referral: 'Referral',
manual: 'Manual',
...CANONICAL_SOURCE_LABELS,
social: 'Social',
unspecified: 'Unspecified',
};
@@ -48,7 +51,7 @@ export function LeadSourceChart({ range }: Props) {
<ChartCard
title="Lead Source Attribution"
description="Where new interests came from"
exportFilename={`lead-source-${range}`}
exportFilename={`lead-source-${rangeToSlug(range)}`}
toCsv={toCsv}
>
{isLoading ? (

View File

@@ -87,7 +87,7 @@ export function MyRemindersRail() {
) : null}
</div>
<Link
href={`/${portSlug}/reminders` as never}
href={`/${portSlug}/inbox#reminders` as never}
className="text-xs font-medium text-primary hover:underline"
>
View all

View File

@@ -15,6 +15,7 @@ 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';
import { rangeToSlug } from '@/lib/analytics/range';
interface Props {
range: DateRange;
@@ -41,7 +42,7 @@ export function OccupancyTimelineChart({ range }: Props) {
<ChartCard
title="Occupancy Timeline"
description="Daily berth occupancy across the range"
exportFilename={`occupancy-timeline-${range}`}
exportFilename={`occupancy-timeline-${rangeToSlug(range)}`}
toCsv={toCsv}
>
{isLoading ? (

View File

@@ -9,6 +9,7 @@ import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
import { ChartCard } from './chart-card';
import { useFunnel } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
import { rangeToSlug } from '@/lib/analytics/range';
interface Props {
range: DateRange;
@@ -38,7 +39,7 @@ export function PipelineFunnelChart({ range }: Props) {
<ChartCard
title="Pipeline Funnel"
description="Interests by stage with conversion rate vs. open"
exportFilename={`pipeline-funnel-${range}`}
exportFilename={`pipeline-funnel-${rangeToSlug(range)}`}
toCsv={toCsv}
>
{isLoading ? (

View File

@@ -0,0 +1,54 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DollarSign } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { formatCurrency } from '@/lib/utils/currency';
interface KpiResponse {
pipelineValueUsd: number;
}
/**
* Total pipeline value for active interests, USD-denominated. Sourced
* from the same KPIs endpoint as the active-deals tile so the two
* share a cache entry and render in lockstep.
*/
export function PipelineValueTile() {
const { data, isLoading } = useQuery<KpiResponse>({
queryKey: ['dashboard', 'kpis'],
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
staleTime: 60_000,
});
return (
<Card>
{/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships
with `pt-0` (it assumes a CardHeader sits above). Without these
overrides the tile content snaps to the top edge of the card. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
<DollarSign className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pipeline value
</p>
{isLoading ? (
<Skeleton className="mt-1 h-7 w-24" />
) : (
<p
className="truncate text-2xl font-bold leading-tight text-foreground"
title={formatCurrency(data?.pipelineValueUsd ?? 0, 'USD')}
>
{formatCurrency(data?.pipelineValueUsd ?? 0, 'USD', { maxFractionDigits: 0 })}
</p>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,7 @@ 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';
import { rangeToSlug } from '@/lib/analytics/range';
import { formatCurrency } from '@/lib/utils/currency';
interface Props {
@@ -42,7 +43,7 @@ export function RevenueBreakdownChart({ range }: Props) {
<ChartCard
title="Revenue Breakdown"
description="Invoice totals grouped by status and currency"
exportFilename={`revenue-breakdown-${range}`}
exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
toCsv={toCsv}
>
{isLoading ? (

View File

@@ -0,0 +1,91 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface SourceRow {
source: string;
total: number;
won: number;
lost: number;
conversionRate: number;
}
interface SourceConversionResponse {
data: SourceRow[];
}
/**
* Horizontal bar list of lead-source conversion rates. Complements the
* existing Lead Source Attribution donut: that one shows where leads
* COME from, this shows which sources actually CONVERT. Lets marketing
* spend follow the buyers, not the tire-kickers.
*
* Renders only sources with at least one lead; uses a compact bar-in-
* row layout so 5-8 sources fit comfortably without scrolling.
*/
export function SourceConversionChart() {
const { data, isLoading } = useQuery<SourceConversionResponse>({
queryKey: ['dashboard', 'source_conversion'],
queryFn: () => apiFetch<SourceConversionResponse>('/api/v1/dashboard/source-conversion'),
staleTime: 60_000,
});
const rows = data?.data ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Source conversion</CardTitle>
<CardDescription>Won deals as a percentage of leads per source.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : rows.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Once interests have a source assigned, conversion rates will appear here.
</p>
) : (
<ul className="space-y-3">
{rows.map((r) => {
const pct = Math.round(r.conversionRate * 100);
const label = r.source
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
<li key={r.source} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">{label}</span>
<span className="tabular-nums text-muted-foreground">
<span className="font-semibold text-foreground">{pct}%</span>
<span className="ml-1.5">
({r.won} won · {r.total} total)
</span>
</span>
</div>
{/* Inline bar — keeps the widget compact and lets eight
rows share the same vertical space a Recharts plot
would use for two. */}
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.max(pct, 2)}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,198 @@
/**
* Dashboard widget registry — the single source of truth for which
* widgets exist, what they're called, where they live, and what they
* default to. The DashboardShell loops over this; the settings UI also
* loops over this. Adding a new widget = adding one entry here.
*
* Widget visibility is persisted per-user in
* `user_profiles.preferences.dashboardWidgets` as `{ [id]: boolean }`.
* Missing entries default to `defaultVisible`, so a brand-new widget
* surfaces for existing users automatically.
*/
import type { ReactNode } from 'react';
import { ActiveDealsTile } from './active-deals-tile';
import { ActivityFeed } from './activity-feed';
import { BerthStatusChart } from './berth-status-chart';
import { HotDealsCard } from './hot-deals-card';
import { LeadSourceChart } from './lead-source-chart';
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { PipelineFunnelChart } from './pipeline-funnel-chart';
import { PipelineValueTile } from './pipeline-value-tile';
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { SourceConversionChart } from './source-conversion-chart';
import { WebsiteGlanceTile } from './website-glance-tile';
import { MyRemindersRail } from './my-reminders-rail';
import { AlertRail } from '@/components/alerts/alert-rail';
import type { DateRange } from '@/lib/analytics/range';
/**
* Where a widget lives on the dashboard. The shell renders three
* separate auto-fit regions so charts and rails don't compete for the
* same horizontal slots (preserves the visual hierarchy the team has
* gotten used to).
*
* - 'chart' → main analytics region (wider min-col)
* - 'rail' → side-rail region (narrower min-col)
* - 'feed' → full-width row underneath everything else
*/
export type WidgetGroup = 'chart' | 'rail' | 'feed';
/**
* External integrations a widget can depend on. When the corresponding
* integration isn't connected for the active port, the widget is hidden
* from the picker AND from the rendered dashboard so reps can't toggle
* something that would render nothing. Wire new integrations through
* `useDashboardIntegrations()`.
*/
export type WidgetIntegration = 'umami' | 'documenso';
export interface DashboardWidget {
/** Stable persistence key. Don't rename — old preferences would break. */
id: string;
label: string;
description: string;
/**
* Renders the widget. Receives the active date-range so chart widgets
* can react; non-chart widgets simply ignore it. Keeping this a
* function instead of a `ComponentType` lets each widget pick its own
* prop shape without leaking the union into the registry type.
*/
render: (range: DateRange) => ReactNode;
group: WidgetGroup;
defaultVisible: boolean;
/**
* Some widgets self-gate (e.g. WebsiteGlanceTile renders null when
* Umami isn't configured). When `true`, the settings UI still shows
* the toggle so admins can enable it once the integration is wired —
* but the widget itself decides whether to render content.
*/
selfGates?: boolean;
/**
* Names the external integration this widget depends on. When the
* integration isn't connected for the active port, the widget is
* filtered out of both the picker and the rendered dashboard.
*/
requires?: WidgetIntegration;
}
export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
// ── KPI tiles (rail) ────────────────────────────────────────────────
// Off by default — keep the existing dashboard layout unchanged for
// users on first paint after the upgrade; reps can flip them on from
// the Customize menu.
{
id: 'kpi_active_deals',
label: 'Active Deals',
description: 'Compact tile: count of in-flight interests.',
render: () => <ActiveDealsTile />,
group: 'rail',
defaultVisible: false,
},
{
id: 'kpi_pipeline_value',
label: 'Pipeline Value',
description: 'Compact tile: total berth value of active deals (USD).',
render: () => <PipelineValueTile />,
group: 'rail',
defaultVisible: false,
},
// ── Charts (main area) ──────────────────────────────────────────────
{
id: 'pipeline_funnel',
label: 'Pipeline Funnel',
description: 'Interests by stage with conversion-rate vs open.',
render: (range) => <PipelineFunnelChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'occupancy_timeline',
label: 'Occupancy Timeline',
description: 'Daily berth occupancy across the range.',
render: (range) => <OccupancyTimelineChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'revenue_breakdown',
label: 'Revenue Breakdown',
description: 'Invoice totals grouped by status and currency.',
render: (range) => <RevenueBreakdownChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'lead_source',
label: 'Lead Source Attribution',
description: 'Where new interests came from.',
render: (range) => <LeadSourceChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'berth_status',
label: 'Berth Status',
description: 'Donut: available / under offer / sold split.',
render: () => <BerthStatusChart />,
group: 'chart',
defaultVisible: false,
},
{
id: 'source_conversion',
label: 'Source Conversion',
description: 'Win rate per lead source — which channels deliver buyers, not just leads.',
render: () => <SourceConversionChart />,
group: 'chart',
defaultVisible: false,
},
{
id: 'website_analytics',
label: 'Website Analytics',
description: 'Quick glance at marketing site traffic. Requires Umami.',
render: () => <WebsiteGlanceTile />,
group: 'rail',
defaultVisible: true,
selfGates: true,
requires: 'umami',
},
{
id: 'my_reminders',
label: 'My Reminders',
description: 'Your upcoming and overdue reminders.',
render: () => <MyRemindersRail />,
group: 'rail',
defaultVisible: true,
},
{
id: 'alerts',
label: 'Alerts',
description: 'System-flagged action items.',
render: () => <AlertRail />,
group: 'rail',
defaultVisible: true,
},
{
id: 'hot_deals',
label: 'Hot Deals',
description: 'Top 5 active interests closest to closing.',
render: () => <HotDealsCard />,
group: 'rail',
defaultVisible: false,
},
{
id: 'activity_feed',
label: 'Recent Activity',
description: 'Audit log of changes across the port.',
render: () => <ActivityFeed />,
group: 'feed',
defaultVisible: true,
},
];
/** Lookup helper so consumers don't have to scan the array. */
export const WIDGETS_BY_ID: Record<string, DashboardWidget> = Object.fromEntries(
DASHBOARD_WIDGETS.map((w) => [w.id, w]),
);