fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes; single grouped commit so the index doesn't fragment into 30 one-liners. User & auth: - `user-settings`: name now updates the avatar + topbar menu after save (was reading stale session). - `me/password-reset`: 3 bugs (token validation, error response shape, redirect chain). - Admin user permission-overrides route honours the same envelope as the rest of the admin surface. Dashboard: - Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card` (replaced by the customisable widget grid). - Strip `revenue_breakdown` from analytics route + use-analytics + service + integration test so nothing renders an empty card. - Activity log timeline overshoot fix (`interest-timeline` + `entity-activity-feed`). - Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile. - `dev-mode-banner`: derive dismissed state synchronously instead of via an effect (set-state-in-effect lint rule). Forms & lists (assorted polish): - client / company / yacht / interest / reminder forms — validation + empty-state copy + tab transitions. - companies/yachts list tweaks; berth recommender panel; qualification checklist; supplemental info request button. Infra & misc: - Queue workers (ai / email / notifications) — log shape + per-job timeout consistency. - Auth / brochures / users schema small adjustments; seeds reflect permissions matrix changes. - Scan shell + scanner manifest + AI admin page small fixes. - `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react` (recommended config from echarts-for-react inside Next). Docs: - `docs/superpowers/audits/alpha-uat-master.md` — single rolling cross-cutting UAT findings doc (per CLAUDE.md convention). - `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log normalization (§J). - 2026-05-18 audit log updated with this batch. - `CLAUDE.md` — small manual UAT scaffold notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,10 +35,11 @@ export function ActiveDealsTile() {
|
||||
|
||||
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">
|
||||
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a
|
||||
CardHeader sits above). The `sm:` variants are required — without
|
||||
them `sm:pt-0` wins at the sm breakpoint and the content snaps to
|
||||
the top edge. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5 sm:pt-5 sm: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>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Berth Heat widget — ranked table of berths by active interest count.
|
||||
* Investor-friendly "where is the demand pressure?" surface. Renders
|
||||
* a sortable table that exports cleanly to PDF/CSV. A future heatmap
|
||||
* visualization can sit beside this table reading the same data.
|
||||
* Berth-demand widget — ranks berths by active interest count, with a
|
||||
* horizontal bar per row encoding magnitude relative to the leader.
|
||||
* Matches the standard CardHeader / CardContent layout of its dashboard
|
||||
* siblings; the bars (not chrome) do the visual work.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ArrowRight, TrendingUp } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatusPill } from '@/components/ui/status-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeatRow {
|
||||
berthId: string;
|
||||
@@ -27,13 +28,16 @@ interface HeatResponse {
|
||||
data: { rows: HeatRow[] };
|
||||
}
|
||||
|
||||
// Render the raw status — StatusPill recognizes 'available' /
|
||||
// 'under_offer' / 'sold' as canonical tokens and applies the right tone.
|
||||
function statusToken(s: string): 'available' | 'under_offer' | 'sold' | 'pending' {
|
||||
if (s === 'available' || s === 'under_offer' || s === 'sold') return s;
|
||||
return 'pending';
|
||||
function formatStatus(s: string): string {
|
||||
return s.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
const BAR_COLOR_BY_STATUS: Record<string, string> = {
|
||||
available: 'bg-success/70',
|
||||
under_offer: 'bg-warning/80',
|
||||
sold: 'bg-error/60',
|
||||
};
|
||||
|
||||
export function BerthHeatWidget() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
@@ -43,52 +47,87 @@ export function BerthHeatWidget() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const ranked = (data?.data?.rows ?? []).filter((r) => r.activeInterestCount > 0);
|
||||
const visible = ranked.slice(0, 6);
|
||||
const max = visible[0]?.activeInterestCount ?? 1;
|
||||
const totalActive = ranked.reduce((sum, r) => sum + r.activeInterestCount, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Berth heat</CardTitle>
|
||||
<CardDescription>Top 15 berths by active interest count.</CardDescription>
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
<TrendingUp className="size-4 text-brand-600" aria-hidden />
|
||||
Berth demand
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{ranked.length > 0
|
||||
? `${totalActive} active interest${totalActive === 1 ? '' : 's'} across ${ranked.length} ${ranked.length === 1 ? 'berth' : 'berths'}.`
|
||||
: 'Berths ranked by active interest count.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden /> Loading…
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.data.rows.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No active interests yet.</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No active demand yet. Berths will appear here as interests land.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="py-1.5 text-left font-medium">Berth</th>
|
||||
<th className="py-1.5 text-left font-medium">Dock</th>
|
||||
<th className="py-1.5 text-left font-medium">Status</th>
|
||||
<th className="py-1.5 text-right font-medium">Interests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.rows.map((r) => (
|
||||
<tr key={r.berthId} className="border-b last:border-b-0">
|
||||
<td className="py-1.5">
|
||||
<>
|
||||
<ul className="space-y-1">
|
||||
{visible.map((r) => {
|
||||
const widthPct = Math.max(6, Math.round((r.activeInterestCount / max) * 100));
|
||||
return (
|
||||
<li key={r.berthId}>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/berths/${r.berthId}` as any}
|
||||
className="font-medium hover:underline"
|
||||
className="-mx-2 grid grid-cols-[3.25rem_1fr_2rem] items-center gap-3 rounded-md px-2 py-2 hover:bg-accent/60"
|
||||
>
|
||||
{r.mooringNumber}
|
||||
<span className="truncate font-mono text-sm font-medium text-foreground">
|
||||
{r.mooringNumber}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
BAR_COLOR_BY_STATUS[r.status] ?? 'bg-brand-400',
|
||||
)}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{r.area ?? 'Unassigned'} ·{' '}
|
||||
<span className="capitalize">{formatStatus(r.status)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-right text-sm font-semibold text-foreground tabular-nums">
|
||||
{r.activeInterestCount}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-1.5 text-muted-foreground">{r.area ?? '—'}</td>
|
||||
<td className="py-1.5">
|
||||
<StatusPill status={statusToken(r.status)}>
|
||||
{r.status.replace(/_/g, ' ')}
|
||||
</StatusPill>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-semibold">{r.activeInterestCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{ranked.length > visible.length ? (
|
||||
<div className="mt-3 border-t pt-2 text-right">
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/berths?sort=activeInterestCount&order=desc` as any}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all by demand
|
||||
<ArrowRight className="size-3" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,57 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface KpiResponse {
|
||||
pipelineValue: number;
|
||||
pipelineValueCurrency: string;
|
||||
activeInterests: number;
|
||||
}
|
||||
|
||||
interface StageRow {
|
||||
stage: string;
|
||||
count: number;
|
||||
grossValue: number;
|
||||
weightedValue: number;
|
||||
weight: number;
|
||||
dealsMissingPrice: number;
|
||||
}
|
||||
|
||||
interface ForecastResponse {
|
||||
totalGrossValue: number;
|
||||
totalWeightedValue: number;
|
||||
stageBreakdown: StageRow[];
|
||||
weightsSource: 'db' | 'default';
|
||||
}
|
||||
|
||||
// Same brand-coloured family the pipeline-funnel chart uses so the two
|
||||
// surfaces feel anchored to the same palette.
|
||||
const STAGE_BAR_CLASS: Record<string, string> = {
|
||||
enquiry: 'bg-slate-300',
|
||||
qualified: 'bg-brand-200',
|
||||
nurturing: 'bg-brand-300',
|
||||
eoi: 'bg-brand-400',
|
||||
reservation: 'bg-amber-400',
|
||||
deposit_paid: 'bg-orange-400',
|
||||
contract: 'bg-success/70',
|
||||
};
|
||||
|
||||
/**
|
||||
* Total pipeline value for active interests, converted to the port's
|
||||
* default currency at display time. Sourced from the same KPIs endpoint
|
||||
* as the active-deals tile so the two share a cache entry and render in
|
||||
* lockstep.
|
||||
* Headline pipeline value plus a per-stage breakdown showing gross
|
||||
* value, deal count, and the weighted forecast (gross × stage close-
|
||||
* probability). Replaces the single-number KPI: leadership can now see
|
||||
* how much of the headline number is near-close vs speculative.
|
||||
*
|
||||
* Pulls from two endpoints: `/kpis` for the gross headline + currency
|
||||
* and `/forecast` for the weighted breakdown. Both share cache entries
|
||||
* with other widgets so this is mostly free.
|
||||
*/
|
||||
export function PipelineValueTile() {
|
||||
const { data, isLoading } = useQuery<KpiResponse>({
|
||||
const kpis = useQuery<KpiResponse>({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const forecast = useQuery<ForecastResponse>({
|
||||
queryKey: ['dashboard', 'forecast'],
|
||||
queryFn: () => apiFetch<ForecastResponse>('/api/v1/dashboard/forecast'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const isLoading = kpis.isLoading || forecast.isLoading;
|
||||
const currency = kpis.data?.pipelineValueCurrency ?? 'USD';
|
||||
const grossTotal = kpis.data?.pipelineValue ?? 0;
|
||||
const weightedTotal = forecast.data?.totalWeightedValue ?? 0;
|
||||
const activeDeals = kpis.data?.activeInterests ?? 0;
|
||||
const activeStages = (forecast.data?.stageBreakdown ?? []).filter((s) => s.count > 0);
|
||||
const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1;
|
||||
|
||||
const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 });
|
||||
|
||||
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" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-2xl font-bold leading-tight text-foreground"
|
||||
title={formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD')}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pipeline value</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<span>
|
||||
{activeDeals > 0
|
||||
? `${activeDeals} active deal${activeDeals === 1 ? '' : 's'} · weighted by stage close-probability`
|
||||
: 'Gross berth value across active deals, with weighted forecast.'}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label="How does the weighted forecast work?"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
{formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD', {
|
||||
maxFractionDigits: 0,
|
||||
})}
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||
<p className="font-semibold text-foreground">How the weighted forecast works</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Each pipeline stage has a close-probability — how likely a deal at that stage is to
|
||||
actually close. Multiplying the berth price by the stage weight gives an{' '}
|
||||
<strong>expected</strong> value for that deal. Summing across every active deal
|
||||
yields the weighted forecast — a defensible “what will likely land”
|
||||
number, vs the gross which assumes every deal closes at full value.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md bg-muted/50 p-2.5 text-[11px]">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const dbWeight = forecast.data?.stageBreakdown.find((r) => r.stage === s)?.weight;
|
||||
const weight = dbWeight ?? STAGE_WEIGHTS[s];
|
||||
return (
|
||||
<div key={s} className="contents">
|
||||
<span className="text-muted-foreground">{stageLabel(s)}</span>
|
||||
<span className="text-right font-medium tabular-nums text-foreground">
|
||||
{Math.round(weight * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">
|
||||
{forecast.data?.weightsSource === 'db'
|
||||
? 'Using per-port weights (admins tune these in Settings → Pipeline).'
|
||||
: 'Using system defaults. Admins can override per port in Settings → Pipeline.'}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* ── Headline numbers ─────────────────────────────────────── */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Gross
|
||||
</p>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1 h-7 w-28" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-2xl font-bold leading-tight text-foreground"
|
||||
title={formatCurrency(grossTotal, currency)}
|
||||
>
|
||||
{fmt(grossTotal)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Weighted forecast
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="ml-auto mt-1 h-6 w-24" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="text-lg font-semibold leading-tight text-foreground tabular-nums"
|
||||
title={formatCurrency(weightedTotal, currency)}
|
||||
>
|
||||
{fmt(weightedTotal)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Per-stage breakdown ─────────────────────────────────── */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
) : activeStages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active deals with linked berths yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{activeStages.map((s) => {
|
||||
const widthPct = Math.max(6, Math.round((s.grossValue / stageMax) * 100));
|
||||
return (
|
||||
<li
|
||||
key={s.stage}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-0.5 py-1"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{stageLabel(s.stage)}
|
||||
</p>
|
||||
<div className="mt-1 flex h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
STAGE_BAR_CLASS[s.stage] ?? 'bg-brand-400',
|
||||
)}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{fmt(s.grossValue)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%
|
||||
</p>
|
||||
{s.dealsMissingPrice > 0 ? (
|
||||
<p
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-warning"
|
||||
title={`${s.dealsMissingPrice} of ${s.count} ${s.count === 1 ? 'deal has' : 'deals have'} a berth with no price set — gross is undercounted here.`}
|
||||
>
|
||||
<AlertTriangle className="size-3" aria-hidden />
|
||||
{s.dealsMissingPrice === s.count
|
||||
? 'berth price missing'
|
||||
: `${s.dealsMissingPrice} of ${s.count} missing price`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{forecast.data?.weightsSource === 'default' ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Using default stage weights. Tune them in Settings → Pipeline.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useRevenue } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
sent: 'Sent',
|
||||
paid: 'Paid',
|
||||
overdue: 'Overdue',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
export function RevenueBreakdownChart({ range }: Props) {
|
||||
const { data, isLoading } = useRevenue(range);
|
||||
const bars = data?.bars ?? [];
|
||||
|
||||
function toCsv(): string | null {
|
||||
if (!bars.length) return null;
|
||||
const header = 'status,currency,amount';
|
||||
const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
const chartData = bars.map((b) => ({
|
||||
label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`,
|
||||
amount: b.amount,
|
||||
currency: b.currency,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Revenue Breakdown"
|
||||
description="Invoice totals grouped by status and currency"
|
||||
exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !bars.length ? (
|
||||
<EmptyState
|
||||
title="No invoices in range"
|
||||
description="Issued, paid, and overdue totals appear here once you create invoices."
|
||||
/>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? 'USD';
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
return [formatCurrency(num, c), 'Amount'];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
MetricBase,
|
||||
OccupancyTimelineData,
|
||||
PipelineFunnelData,
|
||||
RevenueBreakdownData,
|
||||
} from '@/lib/services/analytics.service';
|
||||
|
||||
interface MetricResponse<T> {
|
||||
@@ -51,7 +50,5 @@ export const useFunnel = (range: DateRange) =>
|
||||
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
|
||||
export const useOccupancy = (range: DateRange) =>
|
||||
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
|
||||
export const useRevenue = (range: DateRange) =>
|
||||
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
|
||||
export const useLeadSource = (range: DateRange) =>
|
||||
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);
|
||||
|
||||
@@ -48,10 +48,6 @@ const PipelineFunnelChart = dynamic(
|
||||
() => import('./pipeline-funnel-chart').then((m) => ({ default: m.PipelineFunnelChart })),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const RevenueBreakdownChart = dynamic(
|
||||
() => import('./revenue-breakdown-chart').then((m) => ({ default: m.RevenueBreakdownChart })),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const SourceConversionChart = dynamic(
|
||||
() => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
@@ -123,12 +119,13 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
{
|
||||
id: 'kpi_pipeline_value',
|
||||
label: 'Pipeline Value',
|
||||
description: 'Total berth value of active deals, converted to the port default currency.',
|
||||
description:
|
||||
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
|
||||
render: () => <PipelineValueTile />,
|
||||
group: 'rail',
|
||||
// Flipped on by default 2026-05-14 — the dashboard wave prioritized
|
||||
// investor-facing tiles, and this is the headline number leadership
|
||||
// looks at first.
|
||||
// Lives in the chart grid (not the narrow rail) so the per-stage
|
||||
// breakdown rows have room to breathe alongside the headline numbers,
|
||||
// and the rail stays reserved for reminders / alerts / glance tiles.
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
},
|
||||
|
||||
@@ -149,14 +146,6 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
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',
|
||||
@@ -186,8 +175,9 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
},
|
||||
{
|
||||
id: 'berth_heat',
|
||||
label: 'Berth Heat',
|
||||
description: 'Top 15 berths by active interest count. Investor-friendly demand pressure view.',
|
||||
label: 'Berth Demand',
|
||||
description:
|
||||
'Ranks berths by active interest. Surfaces the leading mooring with its runners-up.',
|
||||
render: () => <BerthHeatWidget />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
@@ -196,7 +186,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
id: 'website_analytics',
|
||||
label: 'Website Analytics',
|
||||
description: 'Quick glance at marketing site traffic. Requires Umami.',
|
||||
render: () => <WebsiteGlanceTile />,
|
||||
render: (range) => <WebsiteGlanceTile range={range} />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
|
||||
Reference in New Issue
Block a user