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:
68
src/components/dashboard/active-deals-tile.tsx
Normal file
68
src/components/dashboard/active-deals-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
101
src/components/dashboard/berth-status-chart.tsx
Normal file
101
src/components/dashboard/berth-status-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
129
src/components/dashboard/customize-widgets-menu.tsx
Normal file
129
src/components/dashboard/customize-widgets-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
108
src/components/dashboard/hot-deals-card.tsx
Normal file
108
src/components/dashboard/hot-deals-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
54
src/components/dashboard/pipeline-value-tile.tsx
Normal file
54
src/components/dashboard/pipeline-value-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
91
src/components/dashboard/source-conversion-chart.tsx
Normal file
91
src/components/dashboard/source-conversion-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/components/dashboard/widget-registry.tsx
Normal file
198
src/components/dashboard/widget-registry.tsx
Normal 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]),
|
||||
);
|
||||
Reference in New Issue
Block a user