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