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,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -22,6 +22,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { PhoneInput } from '@/components/shared/phone-input';
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
@@ -107,6 +108,18 @@ export function ClientForm({
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
const tagIds = watch('tagIds') ?? [];
// Primary-address fields. Live outside RHF because the API splits
// client creation (`POST /api/v1/clients`) from address creation
// (`POST /api/v1/clients/{id}/addresses`) — the address gets chained
// after the client POST returns the new id. Edit mode uses the
// dedicated Addresses tab; the form here is create-only.
const [addressOpen, setAddressOpen] = useState(false);
const [addrStreet, setAddrStreet] = useState('');
const [addrCity, setAddrCity] = useState('');
const [addrSubdivisionIso, setAddrSubdivisionIso] = useState<string | null>(null);
const [addrPostal, setAddrPostal] = useState('');
const [addrCountryIso, setAddrCountryIso] = useState<string | null>(null);
// When the rep picks a country and no timezone is set yet, pre-fill the
// timezone with the country's primary IANA zone. Skips when the user has
// already chosen a zone explicitly so we never clobber a deliberate pick.
@@ -169,10 +182,21 @@ export function ClientForm({
reset({
fullName: prefill?.fullName ?? '',
contacts,
source: prefill?.source,
// Default a manually-created client to `source='manual'` so reps
// don't have to remember to pick it. The inquiry-inbox triage
// flow overrides this via `prefill.source='website'`; the global
// command-search quick-create has no `prefill.source` and
// therefore correctly lands on 'manual'.
source: prefill?.source ?? 'manual',
sourceInquiryId: prefill?.sourceInquiryId,
tagIds: [],
});
setAddressOpen(false);
setAddrStreet('');
setAddrCity('');
setAddrSubdivisionIso(null);
setAddrPostal('');
setAddrCountryIso(null);
}
}, [client, open, reset, prefill]);
@@ -224,7 +248,39 @@ export function ClientForm({
});
}
} else {
await apiFetch('/api/v1/clients', { method: 'POST', body: payload });
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
method: 'POST',
body: payload,
});
// Chain the address POST when any field is filled. Address errors
// don't unwind the client create — surface a toast warning and
// leave the client in place so the rep can finish in the
// Addresses tab.
const hasAddress =
addrStreet.trim() ||
addrCity.trim() ||
addrPostal.trim() ||
addrSubdivisionIso ||
addrCountryIso;
if (hasAddress) {
try {
await apiFetch(`/api/v1/clients/${res.data.id}/addresses`, {
method: 'POST',
body: {
streetAddress: addrStreet.trim() || null,
city: addrCity.trim() || null,
subdivisionIso: addrSubdivisionIso ?? undefined,
postalCode: addrPostal.trim() || null,
countryIso: addrCountryIso ?? undefined,
isPrimary: true,
},
});
} catch {
toast.error(
'Client created but the address could not be saved. Add it from the Addresses tab.',
);
}
}
}
},
onSuccess: () => {
@@ -533,6 +589,95 @@ export function ClientForm({
<Separator />
{/* Primary Address — create-only. Editing happens in the
client detail page's Addresses tab. */}
{!isEdit ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Primary Address
</h3>
{!addressOpen ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAddressOpen(true)}
>
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
Add address
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setAddressOpen(false);
setAddrStreet('');
setAddrCity('');
setAddrSubdivisionIso(null);
setAddrPostal('');
setAddrCountryIso(null);
}}
>
Remove
</Button>
)}
</div>
{addressOpen ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2 space-y-1">
<Label>Street address</Label>
<Input
value={addrStreet}
onChange={(e) => setAddrStreet(e.target.value)}
placeholder="123 Marina Way, Suite 4"
/>
</div>
<div className="space-y-1">
<Label>City</Label>
<Input
value={addrCity}
onChange={(e) => setAddrCity(e.target.value)}
placeholder="Anguilla"
/>
</div>
<div className="space-y-1">
<Label>Postal code</Label>
<Input
value={addrPostal}
onChange={(e) => setAddrPostal(e.target.value)}
placeholder="AI-2640"
/>
</div>
<div className="space-y-1">
<Label>Country</Label>
<CountryCombobox
value={addrCountryIso}
onChange={(iso) => {
setAddrCountryIso(iso ?? null);
// Clear region if country changes — keeps the
// subdivision picker consistent with its country.
setAddrSubdivisionIso(null);
}}
/>
</div>
<div className="space-y-1">
<Label>Region / State</Label>
<SubdivisionCombobox
country={addrCountryIso as CountryCode | null}
value={addrSubdivisionIso}
onChange={(iso) => setAddrSubdivisionIso(iso)}
/>
</div>
</div>
) : null}
</div>
) : null}
{!isEdit ? <Separator /> : null}
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>