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:
2026-05-20 15:56:11 +02:00
parent 8c669e2918
commit 449b9497ab
59 changed files with 1831 additions and 631 deletions

View File

@@ -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 &ldquo;what will likely land&rdquo;
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>
);