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

@@ -81,6 +81,14 @@ interface BerthRecommenderPanelProps {
* Falls back to 'ft' when missing.
*/
desiredUnit?: 'ft' | 'm' | null;
/**
* Number of berths already linked to the interest. When ≥ 1 the panel
* defaults to collapsed (header-only) so the LinkedBerthsList card above
* dominates the rep's attention. They can expand to browse more options
* (multi-berth deals, swap recommendations). Zero / undefined keeps the
* panel expanded so reps see options immediately.
*/
linkedBerthCount?: number;
}
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
@@ -358,6 +366,7 @@ export function BerthRecommenderPanel({
desiredWidthFt,
desiredDraftFt,
desiredUnit,
linkedBerthCount,
}: BerthRecommenderPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
@@ -370,6 +379,11 @@ export function BerthRecommenderPanel({
// single pier (e.g. "show me only A-row matches"). Client-side over
// the already-fetched result set; no service change required.
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
// Collapse state — defaults to collapsed when the deal already has at
// least one linked berth (recommender becomes a "browse more options"
// tool rather than the primary surface). Reps can manually expand any
// time. Header click toggles.
const [collapsed, setCollapsed] = useState<boolean>((linkedBerthCount ?? 0) > 0);
const hasDimensions = desiredLengthFt !== null;
@@ -380,7 +394,9 @@ export function BerthRecommenderPanel({
const { data, isFetching, refetch } = useQuery({
queryKey,
enabled: hasDimensions,
// Skip the network call when collapsed — no point fetching options
// the rep won't see. Re-fires automatically on expand.
enabled: hasDimensions && !collapsed,
queryFn: () =>
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
method: 'POST',
@@ -441,32 +457,56 @@ export function BerthRecommenderPanel({
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{!collapsed ? (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFiltersOpen((v) => !v)}
disabled={!hasDimensions}
>
<Filter className="mr-1.5 size-3.5" aria-hidden />
{filtersOpen ? 'Hide filters' : 'Add filters'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={!hasDimensions || isFetching}
>
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
Refresh
</Button>
</>
) : null}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFiltersOpen((v) => !v)}
disabled={!hasDimensions}
variant="ghost"
onClick={() => setCollapsed((v) => !v)}
aria-expanded={!collapsed}
aria-controls={`recommender-body-${interestId}`}
>
<Filter className="mr-1.5 size-3.5" aria-hidden />
{filtersOpen ? 'Hide filters' : 'Add filters'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={!hasDimensions || isFetching}
>
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
Refresh
{collapsed ? (
<>
<ChevronDown className="mr-1.5 size-3.5" aria-hidden />
Show recommendations
</>
) : (
<>
<ChevronUp className="mr-1.5 size-3.5" aria-hidden />
Hide
</>
)}
</Button>
</div>
</div>
{filtersOpen && hasDimensions ? (
{!collapsed && filtersOpen && hasDimensions ? (
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
) : null}
{hasDimensions && areaChips.length > 1 ? (
{!collapsed && hasDimensions && areaChips.length > 1 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-muted-foreground">Area:</span>
{areaChips.map((letter) => {
@@ -504,55 +544,57 @@ export function BerthRecommenderPanel({
</div>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{!hasDimensions ? (
<p className="text-sm text-muted-foreground">
Once length, width, and draft are set on this interest, the recommender will surface
berths that fit. Edit the desired dimensions on the{' '}
<Link href="?tab=overview" className="text-primary underline">
Overview tab
</Link>
.
</p>
) : isFetching && recommendations.length === 0 ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : recommendations.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
{collapsed ? null : (
<CardContent className="space-y-3" id={`recommender-body-${interestId}`}>
{!hasDimensions ? (
<p className="text-sm text-muted-foreground">
Once length, width, and draft are set on this interest, the recommender will surface
berths that fit. Edit the desired dimensions on the{' '}
<Link href="?tab=overview" className="text-primary underline">
Overview tab
</Link>
.
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
) : isFetching && recommendations.length === 0 ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : recommendations.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
<RecommendationCard
key={rec.berthId}
rec={rec}
portSlug={portSlug}
onAdd={setPendingBerth}
/>
))}
</div>
)}
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
<RecommendationCard
key={rec.berthId}
rec={rec}
portSlug={portSlug}
onAdd={setPendingBerth}
/>
))}
</div>
)}
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
</div>
) : null}
</CardContent>
</div>
) : null}
</CardContent>
)}
{pendingBerth ? (
<AddBerthToInterestDialog

View File

@@ -108,6 +108,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
clientId: '',
yachtId: undefined,
pipelineStage: 'enquiry',
// Default a manually-created interest's source to 'manual' so the
// rep doesn't have to remember to pick it (mirrors the same
// default on client-form.tsx). Inquiry-inbox / website conversion
// flows can override via prefill once that path lands here.
source: 'manual',
reminderEnabled: false,
tagIds: [],
},

View File

@@ -50,7 +50,6 @@ import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
import { PaymentsSection } from '@/components/interests/payments-section';
import { StageGuidanceCard } from '@/components/interests/stage-guidance-card';
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
@@ -59,7 +58,12 @@ import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
type InterestPatchField = 'leadCategory' | 'source';
type InterestPatchField =
| 'leadCategory'
| 'source'
| 'desiredLengthFt'
| 'desiredWidthFt'
| 'desiredDraftFt';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c,
@@ -381,8 +385,9 @@ function MilestoneSection({
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
{isActive ? (
<span className="rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-700">
Next
<span className="inline-flex items-center gap-1 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-white shadow-sm">
<span className="size-1.5 rounded-full bg-white/90" aria-hidden />
Next step
</span>
) : null}
</div>
@@ -844,15 +849,22 @@ function OverviewTab({
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
) : (
// §7.2: replace the empty Payments slot with a stage-aware
// "next step" card on pre-reservation stages so the rep gets
// an actionable prompt instead of dead space.
<StageGuidanceCard
stage={interest.pipelineStage as PipelineStage}
hasLinkedBerth={(interest.linkedBerthCount ?? 0) > 0}
/>
)}
) : null}
{/* Pre-reservation: the dedicated "Next step" guidance card was
removed in favour of a brighter NEXT STEP pill on the active
MilestoneSection below (it already owns the workflow actions —
two surfaces was redundant). Nurturing keeps a slim helper
since no milestone is naturally "current" while a deal is
paused. */}
{interest.pipelineStage === 'nurturing' ? (
<div className="rounded-xl border bg-card p-4 text-sm">
<p className="font-medium text-foreground">Deal is on nurture</p>
<p className="mt-1 text-xs text-muted-foreground">
Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
them back to Qualified.
</p>
</div>
) : null}
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
@@ -1007,6 +1019,41 @@ function OverviewTab({
</dl>
</div>
{/* Berth requirements — desired length / width / draft. Editable
inline so reps can capture or correct a buyer's needs without
leaving the Overview tab. These values drive the auto-tick on
the "Dimensions confirmed" qualification row + the
BerthRecommenderPanel rankings below. */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
<dl>
<EditableRow label="Desired length (ft)">
<InlineEditableField
value={interest.desiredLengthFt ?? null}
onSave={save('desiredLengthFt')}
placeholder="e.g. 60"
emptyText="—"
/>
</EditableRow>
<EditableRow label="Desired width (ft)">
<InlineEditableField
value={interest.desiredWidthFt ?? null}
onSave={save('desiredWidthFt')}
placeholder="e.g. 25"
emptyText="—"
/>
</EditableRow>
<EditableRow label="Desired draft (ft)">
<InlineEditableField
value={interest.desiredDraftFt ?? null}
onSave={save('desiredDraftFt')}
placeholder="e.g. 6"
emptyText="—"
/>
</EditableRow>
</dl>
</div>
{/* Reminder */}
{interest.reminderEnabled && (
<div className="space-y-1">
@@ -1102,6 +1149,7 @@ function OverviewTab({
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0}
/>
{confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
@@ -1197,7 +1245,7 @@ export function getInterestTabs({
},
{
id: 'recommendations',
label: 'Recommendations',
label: 'Berth Recommendations',
content: (
<BerthRecommenderPanel
interestId={interestId}

View File

@@ -109,14 +109,19 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
return (
<div className="relative space-y-0">
{/* Vertical line */}
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
{events.map((event) => {
{events.map((event, idx) => {
const actor = actorLabel(event);
const isAuto = event.userId === 'system';
const isLast = idx === events.length - 1;
return (
<div key={event.id} className="relative flex gap-4 pb-6">
{/* Vertical line — only between bubbles, never trailing past the last. */}
{!isLast && (
<span
aria-hidden
className="absolute left-4 top-8 bottom-0 -translate-x-1/2 w-px bg-border"
/>
)}
{/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
{eventIcon(event)}

View File

@@ -21,6 +21,9 @@ interface QualificationRow {
confirmedBy: string | null;
notes: string | null;
autoSatisfied: boolean;
/** Human-readable explanation of what data drove auto-satisfaction
* (e.g. "Desired: 60 × 25 × 6 ft"). Empty when not auto-satisfied. */
evidence: string;
}
interface QualificationResponse {
@@ -104,9 +107,20 @@ export function QualificationChecklist({
)}
</div>
<ul className="space-y-2">
<ul className="space-y-1.5">
{criteria.map((c) => (
<li key={c.key} className="flex items-start gap-2.5">
<li
key={c.key}
className={cn(
'flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors',
// Unconfirmed rows get a subtle amber accent (left border +
// tinted background) so reps can scan the checklist and
// immediately see what's outstanding. Confirmed rows stay
// muted with line-through; auto-satisfied rows are functionally
// confirmed and follow the confirmed styling.
!c.confirmed && 'border-l-2 border-warning bg-warning-bg/40',
)}
>
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
@@ -125,7 +139,7 @@ export function QualificationChecklist({
className={cn(
'flex-1 text-sm',
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
c.confirmed ? 'text-foreground' : 'text-foreground/90',
c.confirmed ? 'text-muted-foreground' : 'text-foreground',
)}
>
<span className="flex flex-wrap items-center gap-1.5">
@@ -146,6 +160,11 @@ export function QualificationChecklist({
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
) : null}
{c.autoSatisfied && c.evidence ? (
<p className="mt-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
{c.evidence}
</p>
) : null}
</label>
</li>
))}

View File

@@ -60,7 +60,11 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
return (
<Card>
<CardContent className="space-y-3 p-4">
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` because it
assumes a CardHeader sits above. This card is intentionally
header-less, so we restore symmetric padding (`pt-` matches `p-`)
at both base and `sm:` breakpoints. */}
<CardContent className="space-y-3 p-4 pt-4 sm:p-6 sm:pt-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Need more info before drafting the EOI?</h3>
<p className="text-xs text-muted-foreground">