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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user