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:
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user