chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -34,7 +34,7 @@ interface SkippedRow {
}
/**
* Key-based remount of the body when the dialog opens fresh state per
* Key-based remount of the body when the dialog opens - fresh state per
* open without an open→reset useEffect (React Compiler-safe).
*/
export function BulkHardDeleteDialog(props: Props) {

View File

@@ -1,5 +1,6 @@
'use client';
import type { ReactNode } from 'react';
import { Archive, MoreHorizontal, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -9,6 +10,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CountryFlag } from '@/components/shared/country-flag';
import { TagBadge } from '@/components/shared/tag-badge';
import {
ListCard,
@@ -34,7 +36,21 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
const sourceLabel = formatSource(client.source);
const tags = client.tags ?? [];
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
const metaItems: { key: string; node: ReactNode }[] = [];
if (nationality) {
metaItems.push({
key: 'nationality',
node: (
<span className="inline-flex items-center gap-1">
<CountryFlag code={client.nationalityIso} className="h-2.5 w-3.5" decorative />
<ListCardMeta>{nationality}</ListCardMeta>
</span>
),
});
}
if (sourceLabel) {
metaItems.push({ key: 'source', node: <ListCardMeta>{sourceLabel}</ListCardMeta> });
}
const interest = client.latestInterest ?? null;
const interestCount = client.interestCount ?? 0;
@@ -91,12 +107,12 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
<p className="truncate text-sm text-muted-foreground">{primaryContactValue}</p>
) : null}
{meta.length > 0 ? (
{metaItems.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{meta.map((m, i) => (
<span key={m} className="inline-flex items-center gap-1">
{metaItems.map((m, i) => (
<span key={m.key} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{m}</ListCardMeta>
{m.node}
</span>
))}
</div>

View File

@@ -30,7 +30,7 @@ export interface ContactRow {
interface Props {
clientId: string;
/**
* Channel filter picker shows only `email` (or `phone` + `whatsapp` for
* Channel filter - picker shows only `email` (or `phone` + `whatsapp` for
* phone-style channels). Edits / promotions stay scoped to the chosen
* channel.
*/
@@ -39,11 +39,11 @@ interface Props {
* value rendering when the picker isn't open). */
primaryContactId: string | null;
primaryValue: string | null;
/** Phone channel only E.164 form + ISO-3166-1 alpha-2 country code so the
/** Phone channel only - E.164 form + ISO-3166-1 alpha-2 country code so the
* inline phone editor can preserve the national-format roundtrip. */
primaryValueE164?: string | null;
primaryValueCountry?: string | null;
/** Query keys to invalidate after any mutation succeeds the parent
/** Query keys to invalidate after any mutation succeeds - the parent
* detail view is usually keyed on `['interest', interestId]` or
* `['clients', clientId]` so the picker can't hard-code which to bump. */
invalidateKeys?: ReadonlyArray<readonly unknown[]>;

View File

@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { CountryFlag } from '@/components/shared/country-flag';
import { getCountryName } from '@/lib/i18n/countries';
import { stageDotClass, stageLabel, formatSource, formatOutcome } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -29,7 +30,7 @@ export interface ClientRow {
createdAt: string;
primaryEmail?: string | null;
primaryPhone?: string | null;
/** E.164 (digits + leading +) used to build wa.me / tel: links. */
/** E.164 (digits + leading +) - used to build wa.me / tel: links. */
primaryPhoneE164?: string | null;
yachtCount?: number;
companyCount?: number;
@@ -39,7 +40,7 @@ export interface ClientRow {
* Berths the client has interests in (active only) with the most-active
* interest's stage attached. Sorted server-side: open deals first, most
* progressed stage first, then mooring alphabetical. Each chip in the
* list view links to the interest, not the berth that's the action
* list view links to the interest, not the berth - that's the action
* sales reps want.
*/
linkedBerths?: Array<{
@@ -53,7 +54,7 @@ export interface ClientRow {
}
/**
* Picker manifest drives the `<ColumnPicker>` dropdown next to the
* Picker manifest - drives the `<ColumnPicker>` dropdown next to the
* filter bar. Order here is the order shown in the menu. `alwaysVisible`
* marks columns the user can't hide (otherwise the table is unusable).
*
@@ -76,7 +77,7 @@ export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
/**
* Default-hidden columns for a fresh user. The hook merges this with
* the user's saved overrides once they explicitly toggle a column,
* the user's saved overrides - once they explicitly toggle a column,
* their choice wins. New columns surface for existing users by default
* (they're absent from the user's stored hidden list).
*/
@@ -174,8 +175,12 @@ export function getClientColumns({
header: 'Country',
cell: ({ getValue }) => {
const iso = getValue() as string | null;
if (!iso) return <span className="text-muted-foreground">-</span>;
return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<CountryFlag code={iso} className="h-3 w-4" decorative />
<span>{getCountryName(iso, 'en')}</span>
</span>
);
},
},
@@ -264,7 +269,7 @@ export function getClientColumns({
},
},
{
// Hidden by default the per-berth stage is now carried by each
// Hidden by default - the per-berth stage is now carried by each
// chip in the Berths column, so this standalone column is only
// useful when a user has explicitly toggled it on.
id: 'latestStage',
@@ -327,10 +332,10 @@ export function getClientColumns({
/**
* Single berth-with-stage chip used in the inline (top-2) chip row of
* the Berths column. Shows mooring + full stage label, with a colored
* dot for stage reinforcement (decorative the label carries the
* dot for stage reinforcement (decorative - the label carries the
* meaning so color-blind / no-hover users don't lose anything).
*
* Click target is the *interest*, not the berth the user almost
* Click target is the *interest*, not the berth - the user almost
* always wants to act on the deal, not look at the berth's static
* specs. Outcome-set rows (won/lost/cancelled) get a muted dot so they
* read as historical context rather than active work.

View File

@@ -16,6 +16,7 @@ import { HardDeleteDialog } from '@/components/clients/hard-delete-dialog';
import { ReminderForm } from '@/components/reminders/reminder-form';
import { useQueryClient } from '@tanstack/react-query';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { CountryFlag } from '@/components/shared/country-flag';
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
import { cn } from '@/lib/utils';
@@ -69,7 +70,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const addedLabel = client.createdAt
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
: null;
const meta = [country, addedLabel].filter(Boolean) as string[];
return (
<>
@@ -87,8 +87,21 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)}
</div>
{meta.length > 0 ? (
<p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p>
{country || addedLabel ? (
<p className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground sm:text-sm">
{country ? (
<span className="inline-flex items-center gap-1.5">
<CountryFlag
code={client.nationalityIso}
className="h-3 w-4 sm:h-3.5 sm:w-5"
decorative
/>
<span>{country}</span>
</span>
) : null}
{country && addedLabel ? <span aria-hidden>·</span> : null}
{addedLabel ? <span>{addedLabel}</span> : null}
</p>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">

View File

@@ -42,7 +42,7 @@ interface ClientFormProps {
* or opening the create-interest dialog pre-filled with that
* clientId. Skipped in edit mode. */
onUseExistingClient?: (clientId: string) => void;
/** Optional initial values for the create flow used by the
/** Optional initial values for the create flow - used by the
* inquiry-inbox "Convert to client" triage step (P-4.5) so the rep
* doesn't retype values they just read in the inbox. The
* `sourceInquiryId` is persisted to `clients.source_inquiry_id` on
@@ -110,7 +110,7 @@ export function ClientForm({
// 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
// (`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);
@@ -217,7 +217,7 @@ export function ClientForm({
}
// Primary is per-channel (DB has a partial unique index on
// (client_id, channel) WHERE is_primary). For every channel present
// in the cleaned set, ensure exactly one row is flagged primary
// in the cleaned set, ensure exactly one row is flagged primary -
// promote the first row of that channel if none was explicitly
// marked, and clear duplicates so the API doesn't 409.
const seenPrimaryByChannel = new Set<string>();
@@ -225,7 +225,7 @@ export function ClientForm({
if (c.isPrimary && !seenPrimaryByChannel.has(c.channel)) {
seenPrimaryByChannel.add(c.channel);
} else if (c.isPrimary) {
// duplicate primary within the channel clear
// duplicate primary within the channel - clear
c.isPrimary = false;
}
}
@@ -253,7 +253,7 @@ export function ClientForm({
body: payload,
});
// Chain the address POST when any field is filled. Address errors
// don't unwind the client create surface a toast warning and
// 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 =
@@ -467,7 +467,7 @@ export function ClientForm({
const checked = !!v;
const thisChannel = watch(`contacts.${index}.channel`);
if (checked) {
// Primary is per-channel flipping this one on
// Primary is per-channel - flipping this one on
// clears the flag on every other row sharing the
// same channel. (DB enforces uniqueness via a
// partial index, but doing it client-side avoids
@@ -589,7 +589,7 @@ export function ClientForm({
<Separator />
{/* Primary Address create-only. Editing happens in the
{/* Primary Address - create-only. Editing happens in the
client detail page's Addresses tab. */}
{!isEdit ? (
<div className="space-y-3">
@@ -657,7 +657,7 @@ export function ClientForm({
value={addrCountryIso}
onChange={(iso) => {
setAddrCountryIso(iso ?? null);
// Clear region if country changes keeps the
// Clear region if country changes - keeps the
// subdivision picker consistent with its country.
setAddrSubdivisionIso(null);
}}

View File

@@ -170,7 +170,7 @@ export function ClientList() {
});
// Per-user column visibility, persisted into user_profiles.preferences
// via /api/v1/me. Hidden IDs are the source of truth `actions` and
// via /api/v1/me. Hidden IDs are the source of truth - `actions` and
// `select` columns aren't user-toggleable so they're never in the
// hidden set. New columns surface for existing users by default.
const { hidden, setHidden } = useTablePreferences('clients', CLIENT_DEFAULT_HIDDEN);
@@ -190,7 +190,7 @@ export function ClientList() {
<SavedViewsDropdown
entityType="clients"
onApplyView={(savedFilters, _savedSort) => {
// Atomic replace sequential setFilter() calls dropped all
// Atomic replace - sequential setFilter() calls dropped all
// but the last value (each one read stale `filters` from
// closure and overwrote). setAllFilters writes the whole
// saved view in one setState.

View File

@@ -28,7 +28,7 @@ export interface ClientInterestRow {
dateLastContact: string | null;
berthMooringNumber?: string | null;
yachtName?: string | null;
/** Requirements surfaced on the Client Overview panel "Wants L × W × D
/** Requirements surfaced on the Client Overview panel - "Wants L × W × D
* · Source" lets reps see what the deal is looking for without drilling
* into the Interest detail. Fields are nullable when the rep hasn't
* captured constraints yet. */
@@ -88,7 +88,7 @@ export function StageStepper({
);
})}
</div>
{/* Stage-name row below the bar surfaces all reached stage names
{/* Stage-name row below the bar - surfaces all reached stage names
inline (compact short-labels) so the bar isn't a mystery without
hovering. Future stages render in muted text so the rep can still
see the ladder ahead. The `xs` size variant hides this row to
@@ -323,7 +323,7 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
</span>
</div>
{/* Requirements one-liner: "Wants 50ft × 18ft × 8ft · Referral".
Hidden when the rep hasn't captured any constraints yet
Hidden when the rep hasn't captured any constraints yet -
noise reduction over empty placeholders. */}
{(() => {
const dims = [i.desiredLengthFt, i.desiredWidthFt, i.desiredDraftFt]

View File

@@ -169,7 +169,7 @@ function OverviewTab({
value={client.nationalityIso ?? null}
onSave={async (iso) => {
// Auto-default the timezone to the country's primary
// zone when none is set yet saves the rep a click
// zone when none is set yet - saves the rep a click
// and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a

View File

@@ -32,7 +32,7 @@ interface Contact {
valueCountry?: string | null;
label?: string | null;
isPrimary: boolean;
/** Phase 3d origin tag surfaced as an [EOI] badge when an EOI
/** Phase 3d - origin tag surfaced as an [EOI] badge when an EOI
* spawned this contact. */
source?: string | null;
sourceDocumentId?: string | null;
@@ -230,7 +230,7 @@ function ContactRow({
</div>
{/* Override history is only meaningful for the canonical "primary
email" / "primary phone" entries the supplemental form
overwrites secondary contacts don't have a matching
overwrites - secondary contacts don't have a matching
bindable path. The icon renders nothing when no rows exist. */}
{contact.isPrimary && contact.channel === 'email' ? (
<FieldHistoryIcon fieldPath="client.primaryEmail" />
@@ -276,7 +276,7 @@ function ContactRow({
className="inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800"
title={
contact.sourceDocumentId
? 'Spawned from an EOI open the source document for details.'
? 'Spawned from an EOI - open the source document for details.'
: 'Spawned from an EOI override.'
}
>

View File

@@ -20,7 +20,7 @@ interface MatchData {
emails: string[];
phonesE164: string[];
/** ISO timestamp when the client was archived. When set, the matched
* client is soft-deleted the suggestion panel surfaces a Restore link
* client is soft-deleted - the suggestion panel surfaces a Restore link
* to the existing restore wizard instead of "Use this client". */
archivedAt: string | null;
}
@@ -137,7 +137,7 @@ export function DedupSuggestionPanel({
? 'This contact info belongs to an archived client'
: isHigh
? 'This looks like an existing client'
: 'Possible match check before creating'}
: 'Possible match - check before creating'}
</p>
{isArchived && (
<p className="mt-0.5 text-xs text-muted-foreground">

View File

@@ -35,7 +35,7 @@ type Stage = 'intent' | 'confirm';
* Outer wrapper keeps the Dialog mounted (so its close animation runs);
* the body only mounts when `open` is true and remounts on each
* open via the `clientId` key. This avoids the open→reset-state
* useEffect that React Compiler flags fresh state per open is just
* useEffect that React Compiler flags - fresh state per open is just
* the natural mount.
*/
export function HardDeleteDialog(props: Props) {

View File

@@ -58,7 +58,7 @@ export function SendDocumentsDialog({
| null
>(null);
// Lightweight brochures fetch only fires once dialog is opened.
// Lightweight brochures fetch - only fires once dialog is opened.
const brochuresQuery = useQuery<BrochuresResponse>({
queryKey: ['brochures', 'list'],
queryFn: () => apiFetch('/api/v1/admin/brochures'),

View File

@@ -206,7 +206,7 @@ function SmartArchiveDialogBody({
if (!dossier) throw new Error('No dossier');
// Pick the first linked interest for this berth from the
// authoritative dossier join. Berths with no linked interest for
// this client are skipped sending an empty interestId would
// this client are skipped - sending an empty interestId would
// make the server-side delete silently match zero rows.
const berthDec = dossier.berths
.map((b) => {