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