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

@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { CountryFlag } from '@/components/shared/country-flag';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -234,14 +235,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
);
}
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
function flagEmoji(code: string | null | undefined): string {
if (!code || code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
}
function CountryFieldInline({
value,
onSave,
@@ -277,14 +270,20 @@ function CountryFieldInline({
/>
);
}
const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null;
return (
<button
type="button"
onClick={() => setEditing(true)}
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
>
{display ?? <span className="text-muted-foreground italic">Not set</span>}
{value ? (
<span className="inline-flex items-center gap-1.5">
<CountryFlag code={value} className="h-3 w-4" decorative />
<span>{getCountryName(value, 'en')}</span>
</span>
) : (
<span className="text-muted-foreground italic">Not set</span>
)}
</button>
);
}

View File

@@ -80,7 +80,7 @@ export function BerthPicker({
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
// Free-text search path used when there's no clientId scope.
// Free-text search path - used when there's no clientId scope.
const { data: searchData } = useQuery<{ data: BerthOption[] }>({
queryKey: ['berth-picker', 'search', debounced],
queryFn: () => {
@@ -94,7 +94,7 @@ export function BerthPicker({
enabled: open && !clientId,
});
// Scoped path pull this client's interests (with their primary
// Scoped path - pull this client's interests (with their primary
// berth) and dedupe the berth set.
const { data: clientInterests } = useQuery<{
data: Array<{ berthId: string | null; berthMooringNumber: string | null }>;
@@ -152,7 +152,7 @@ export function BerthPicker({
return (
// `modal` is required when this picker is rendered inside a Sheet /
// Dialog without it the CommandInput stays focus-blocked by the
// Dialog - without it the CommandInput stays focus-blocked by the
// outer Sheet's focus trap and clicks/typing are silently dropped.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>

View File

@@ -16,7 +16,7 @@ interface BrandedAuthShellProps {
}
/**
* Branded shell shared by every auth/form surface CRM login, portal login,
* Branded shell shared by every auth/form surface - CRM login, portal login,
* password set/reset/activate, forgot-password. Renders the background,
* the port logo, and a centered white card that consumers populate with
* their own form/content.
@@ -29,12 +29,12 @@ export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps)
const ctx = useAuthBranding();
const logoUrl = branding?.logoUrl ?? ctx?.logoUrl ?? null;
const backgroundUrl = branding?.backgroundUrl ?? ctx?.backgroundUrl ?? null;
// When no port name is known, treat the logo as decorative "Sign in"
// When no port name is known, treat the logo as decorative - "Sign in"
// as alt text was being read on every auth page even when the page
// itself isn't a sign-in surface (e.g. password reset, set-password).
const appName = branding?.appName ?? ctx?.appName ?? null;
const altText = appName ?? '';
// fixed inset-0 anchors the auth surface to the viewport directly
// fixed inset-0 anchors the auth surface to the viewport directly -
// iOS Safari ignores overflow-hidden on inner divs for body-level
// scrolling, so a regular `h-dvh overflow-hidden` wrapper doesn't
// stop the rubber-band bounce. Pinning to the viewport via position

View File

@@ -49,17 +49,29 @@ export function ClientPicker({
enabled: open,
});
// The search results are paginated and only fetched while the popover
// is open, so a `value` set from outside (e.g. an existing reservation)
// can't be name-resolved from them. A second query targets the picked
// client directly so the trigger label reads as the rep's name, not a
// UUID-prefix fallback.
const { data: selectedData } = useQuery<{ data: { id: string; fullName: string } }>({
queryKey: ['client-picker', 'selected', value],
queryFn: () => apiFetch(`/api/v1/clients/${value}`),
enabled: !!value,
staleTime: 5 * 60_000,
});
const options = data?.data ?? [];
const selectedLabel = (() => {
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
return match?.fullName ?? `Client ${value.slice(0, 8)}`;
return match?.fullName ?? selectedData?.data?.fullName ?? `Client ${value.slice(0, 8)}`;
})();
return (
// `modal` is required when this picker is rendered inside a Sheet /
// Dialog without it the CommandInput stays focus-blocked by the
// Dialog - without it the CommandInput stays focus-blocked by the
// outer Sheet's focus trap and clicks/typing are silently dropped.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>

View File

@@ -26,7 +26,7 @@ export interface ColumnPickerOption {
/**
* Dropdown menu for toggling table column visibility. Lives next to the
* filter bar single source of truth for which columns the current
* filter bar - single source of truth for which columns the current
* user wants to see in this table. Persistence is handled by the
* parent (typically via `useTablePreferences`).
*/
@@ -41,7 +41,7 @@ export function ColumnPicker({
onChange: (hidden: string[]) => void;
/**
* Optional callback. When provided, a "Save current view" item is
* appended to the menu folds the save-view affordance into the
* appended to the menu - folds the save-view affordance into the
* column picker instead of a separate top-level button.
*/
onSaveView?: () => void;
@@ -64,7 +64,7 @@ export function ColumnPicker({
}
// The "All visible" affordance is only useful when something is
// hidden a no-op button is noise.
// hidden - a no-op button is noise.
const canShowAll = hidden.some((id) =>
columns.some((col) => col.id === id && !col.alwaysVisible),
);
@@ -75,7 +75,7 @@ export function ColumnPicker({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Hide entirely on mobile small viewports render the list as
{/* Hide entirely on mobile - small viewports render the list as
cards (no columns to pick). The desktop table view shows
this trigger from the `sm:` breakpoint up. */}
<Button variant="outline" size="sm" className="hidden sm:inline-flex gap-1.5 h-8">

View File

@@ -13,6 +13,7 @@ import {
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { CountryFlag } from '@/components/shared/country-flag';
import { cn } from '@/lib/utils';
import { ALL_COUNTRY_CODES, getCountryName, type CountryCode } from '@/lib/i18n/countries';
@@ -38,21 +39,6 @@ interface CountryComboboxProps {
onOpenChange?: (open: boolean) => void;
}
/**
* Returns the regional-indicator emoji flag for an ISO alpha-2 code.
* E.g. 'GB' → 🇬🇧. Avoids shipping a flag-image asset and respects the
* platform's emoji rendering (iOS/macOS render real flags; Windows
* shows the country code on a flag rectangle).
*/
function flagEmoji(code: string): string {
if (code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
const cp1 = A + code.charCodeAt(0) - a;
const cp2 = A + code.charCodeAt(1) - a;
return String.fromCodePoint(cp1, cp2);
}
export function CountryCombobox({
value,
onChange,
@@ -80,7 +66,6 @@ export function CountryCombobox({
return ALL_COUNTRY_CODES.map((code) => ({
code,
name: getCountryName(code, effectiveLocale),
flag: flagEmoji(code),
})).sort((a, b) => a.name.localeCompare(b.name, effectiveLocale));
}, [effectiveLocale]);
@@ -89,7 +74,7 @@ export function CountryCombobox({
return (
// modal: required when this combobox is nested inside a Sheet
// (Radix Dialog). Without it, the parent Dialog's pointer-events
// handling swallows the trigger's tap on iOS Safari same fix
// handling swallows the trigger's tap on iOS Safari - same fix
// pattern as TimezoneCombobox.
<Popover modal open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
@@ -112,7 +97,7 @@ export function CountryCombobox({
>
{selected ? (
<span className="flex min-w-0 items-center gap-2">
<span className="text-base leading-none">{selected.flag}</span>
<CountryFlag code={selected.code} className="h-3 w-4" decorative />
{!compact ? (
<span className="truncate text-sm">{selected.name}</span>
) : (
@@ -158,7 +143,7 @@ export function CountryCombobox({
<Check
className={cn('mr-2 h-4 w-4', value === opt.code ? 'opacity-100' : 'opacity-0')}
/>
<span className="mr-2 text-base leading-none">{opt.flag}</span>
<CountryFlag code={opt.code} className="mr-2 h-3 w-4" decorative />
<span className="flex-1 truncate text-sm">{opt.name}</span>
<span className="text-xs text-muted-foreground">{opt.code}</span>
</CommandItem>

View File

@@ -0,0 +1,117 @@
'use client';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { getCountryName } from '@/lib/i18n/countries';
interface CountryFlagProps {
/** ISO-3166-1 alpha-2 code (case-insensitive). Renders nothing when null/empty
* so callers can `<CountryFlag code={client.nationalityIso} />` without guards. */
code: string | null | undefined;
/** Tailwind size classes (default `h-3 w-4`). The 3x2 SVG ratio renders crisp
* at any width - pick `h-4 w-5` for chips, `h-3 w-4` for inline rail rows,
* `h-5 w-7` for detail headers. */
className?: string;
/** Override the aria-label/title (defaults to the localized country name). */
title?: string;
/** Hide from assistive tech when the flag is purely decorative (e.g. shown next
* to a visible country name that already conveys the same info). */
decorative?: boolean;
}
// Single lazy import of the whole 3×2 string-SVG index. Per-code dynamic
// imports with template strings don't enumerate cleanly through the
// package's `exports` field in Next.js's webpack build, so we fall back
// to one chunk that holds every flag and pluck by key. The chunk is
// only fetched the first time any flag renders in the session and is
// cached on the module's promise - every subsequent CountryFlag instance
// resolves instantly.
let flagsPromise: Promise<Record<string, string>> | null = null;
let flagsCache: Record<string, string> | null = null;
function loadAllFlags(): Promise<Record<string, string>> {
if (flagsCache) return Promise.resolve(flagsCache);
if (!flagsPromise) {
flagsPromise = import('country-flag-icons/string/3x2')
.then((mod) => {
flagsCache = mod as unknown as Record<string, string>;
return flagsCache;
})
.catch((err: unknown) => {
flagsPromise = null;
throw err;
});
}
return flagsPromise;
}
/**
* Lazy-loaded SVG country flag. Uses the `country-flag-icons` 3×2 string
* variant via dynamic import, so only the flags actually rendered in a
* session are downloaded (one ~12 KB chunk per country, cached). Falls
* back to a muted placeholder for unknown codes or during the initial
* load tick - accepted, since the surrounding text label conveys the
* same info.
*
* Use everywhere we previously rendered an ISO code chip or a regional-
* indicator emoji glyph; the SVG path renders identically on every
* platform (notably Windows, where the emoji form falls back to the
* letter pair).
*/
export function CountryFlag({ code, className, title, decorative }: CountryFlagProps) {
const normalized = code ? code.toUpperCase() : '';
const [svg, setSvg] = useState<string | null>(() =>
normalized && flagsCache ? (flagsCache[normalized] ?? null) : null,
);
useEffect(() => {
if (!normalized) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear cached SVG when caller drops the code
setSvg(null);
return;
}
if (flagsCache) {
setSvg(flagsCache[normalized] ?? null);
return;
}
let cancelled = false;
loadAllFlags()
.then((flags) => {
if (!cancelled) setSvg(flags[normalized] ?? null);
})
.catch(() => {
if (!cancelled) setSvg(null);
});
return () => {
cancelled = true;
};
}, [normalized]);
if (!normalized) return null;
const label = title ?? getCountryName(normalized, 'en') ?? normalized;
const baseClass = 'inline-block shrink-0 overflow-hidden rounded-[2px]';
const sizeClass = className ?? 'h-3 w-4';
if (!svg) {
return (
<span
className={cn(baseClass, sizeClass, 'bg-muted')}
aria-hidden={decorative ? true : undefined}
aria-label={decorative ? undefined : label}
title={decorative ? undefined : label}
/>
);
}
return (
<span
className={cn(baseClass, sizeClass, '[&>svg]:h-full [&>svg]:w-full')}
role={decorative ? undefined : 'img'}
aria-hidden={decorative ? true : undefined}
aria-label={decorative ? undefined : label}
title={decorative ? undefined : label}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View File

@@ -54,7 +54,7 @@ interface DataTableProps<TData> {
getRowId?: (row: TData) => string;
onRowClick?: (row: TData) => void;
/**
* Optional row class hook return a string of Tailwind utilities
* Optional row class hook - return a string of Tailwind utilities
* applied to the `<TableRow>`. Use for visual grouping (e.g. tinting
* berths by mooring letter so the kanban-like grid reads at a glance).
*/
@@ -71,14 +71,14 @@ interface DataTableProps<TData> {
* rows that share the same returned key are visually grouped under a
* header showing the key. Rendered only on mobile (next to cardRender);
* the desktop table is unaffected. Useful for berths-by-area,
* documents-by-folder, etc. pre-sort the data on the same key so
* documents-by-folder, etc. - pre-sort the data on the same key so
* adjacent rows already share groups.
*/
mobileGroupBy?: (row: TData) => string | null | undefined;
/**
* Per-column visibility map. Keys are column IDs, values mean
* "currently visible". Columns absent from the map are visible by
* default newly-added columns surface for existing users without
* default - newly-added columns surface for existing users without
* needing a preferences migration.
*/
columnVisibility?: VisibilityState;
@@ -86,13 +86,13 @@ interface DataTableProps<TData> {
* Row density. `'comfortable'` (default) keeps shadcn's default
* cell padding. `'compact'` drops vertical padding so reps can scan
* more rows per viewport, useful for berth tables and admin lists.
* Affects desktop only the mobile card list ignores it.
* Affects desktop only - the mobile card list ignores it.
*/
density?: 'comfortable' | 'compact';
/**
* Opt-in row virtualization. Only renders rows in the viewport (plus a
* small overscan), so a 5000-row client-export list stays at 60 fps.
* The virtualizer uses the surrounding scroll container set
* The virtualizer uses the surrounding scroll container - set
* `virtualHeightPx` (default 600) so we know the visible area at mount.
*
* Off by default because most CRM tables are server-paginated to 20-50
@@ -101,7 +101,7 @@ interface DataTableProps<TData> {
*
* Constraints:
* - Pagination is incompatible with `virtual` (virtualize ALL rows or
* paginate pick one). When both are passed, pagination wins and
* paginate - pick one). When both are passed, pagination wins and
* virtualization is silently disabled.
* - Mobile card view is unaffected; virtualization only applies to the
* desktop `<Table>` rendering at lg: and up.
@@ -224,7 +224,7 @@ export function DataTable<TData>({
const rows = table.getRowModel().rows;
// Virtualization gate pagination wins over virtual (you can't do both).
// Virtualization gate - pagination wins over virtual (you can't do both).
const virtualEnabled = Boolean(virtual) && !pagination;
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
@@ -411,7 +411,7 @@ export function DataTable<TData>({
</ul>
)}
{/* Pagination render whenever pagination is defined so the
{/* Pagination - render whenever pagination is defined so the
page-size selector is reachable even on single-page tables.
Prev/Next group only renders when there's actually more than
one page. */}
@@ -423,7 +423,7 @@ export function DataTable<TData>({
: `${pagination.total} row(s) total`}
</div>
<div className="flex items-center gap-3">
{/* Page-size selector "All" maps to the validator's
{/* Page-size selector - "All" maps to the validator's
max(1000) cap. If a port has more than 1000 active rows
the user paginates; we don't quietly drop rows. */}
<label className="text-sm text-muted-foreground inline-flex items-center gap-1.5">

View File

@@ -5,7 +5,7 @@ import { SearchX } from 'lucide-react';
import { EmptyState } from '@/components/shared/empty-state';
interface DetailNotFoundProps {
/** "interest", "client", "yacht", etc. used to build the copy. */
/** "interest", "client", "yacht", etc. - used to build the copy. */
entity: string;
/** Plural list path back-link. e.g. "/port-x/clients". */
backHref: string;

View File

@@ -20,10 +20,10 @@ const DISMISS_KEY = 'pn-crm.devBanner.dismissed';
* every outbound email is being rerouted to a single inbox.
*
* Production hides the banner entirely because env.ts refuses to boot
* with EMAIL_REDIRECT_TO set when NODE_ENV=production the flag is
* with EMAIL_REDIRECT_TO set when NODE_ENV=production - the flag is
* only ever non-null in dev / staging.
*
* Dismissal is persisted in localStorage keyed by the redirect address
* Dismissal is persisted in localStorage keyed by the redirect address -
* changing `EMAIL_REDIRECT_TO` re-shows the banner so the new target
* can't be silently inherited.
*/

View File

@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils';
// overlay alone provides enough depth signal. Individual call sites can
// still opt back in if they have a lightweight page underneath.
//
// Also default `repositionInputs={false}` when the drawer has form
// Also default `repositionInputs={false}` - when the drawer has form
// inputs, Vaul's viewport repositioning logic conflicts with iOS's
// keyboard handling and produces the visible scroll-then-jump we hit
// in the search overlay.

View File

@@ -78,7 +78,7 @@ function formatValueForField(field: string | null, value: unknown): string {
* 4. No field → "<verb> this record"
*
* Truncation at 60 chars on the inline value keeps long body fields
* (notes, descriptions) from blowing out the row the diff line
* (notes, descriptions) from blowing out the row - the diff line
* below still renders the full value if the rep clicks through.
*/
function sentence(row: AuditRow, actor: string): string {
@@ -148,7 +148,7 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
staleTime: 30_000,
});
// Filter state chips above the feed. Empty selection = no filter.
// Filter state - chips above the feed. Empty selection = no filter.
const [actorFilter, setActorFilter] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState<string | null>(null);
@@ -255,7 +255,7 @@ function SessionGroupItem({ group, isLast }: { group: SessionGroup; isLast: bool
const created = new Date(first.createdAt);
const ago = formatDistanceToNow(created, { addSuffix: true });
// Vertical connector runs from below this bubble down to the next item,
// Vertical connector - runs from below this bubble down to the next item,
// omitted on the last item so the line never trails past the last bubble.
const connector = !isLast ? (
<span
@@ -358,7 +358,7 @@ function FilterChipMenu({
onChange: (v: string | null) => void;
options: { value: string; label: string }[];
}) {
// Lightweight native <select>-style chip keeps the activity feed
// Lightweight native <select>-style chip - keeps the activity feed
// self-contained without pulling in a popover or radix dropdown for
// what is essentially "two filter chips".
return (

View File

@@ -17,7 +17,7 @@
* reverse-chronological diff list.
*
* Fields without `historyPath`, or fields whose path has zero history
* rows, render nothing extra the surface is purely additive.
* rows, render nothing extra - the surface is purely additive.
*/
import { createContext, useContext, useMemo, type ReactNode } from 'react';
@@ -73,7 +73,7 @@ export function FieldHistoryProvider({ scope, children }: ProviderProps) {
},
// Field history is small + read-mostly. 30s is plenty fresh for the
// common case where the rep edits a field and wants to see the
// history reflect immediately react-query refetches on focus.
// history reflect immediately - react-query refetches on focus.
staleTime: 30_000,
});

View File

@@ -201,7 +201,7 @@ function FilterField({
// Radix Select forbids empty-string item values (throws at render
// time, crashes the page). Use a sentinel and translate.
const ANY = '__any__';
// Hide the field entirely when there's nothing to pick avoids a
// Hide the field entirely when there's nothing to pick - avoids a
// bare "Status" / "Stage" label sitting above an empty dropdown
// before the parent has loaded options.
if (!definition.options || definition.options.length === 0) return null;
@@ -229,7 +229,7 @@ function FilterField({
}
case 'multi-select': {
// Hide the entire field label + checkbox list when there's
// Hide the entire field - label + checkbox list - when there's
// nothing to filter by. Tags/segments/etc. that aren't configured
// yet would otherwise show as a "Tags" header with an empty box
// beneath it.

View File

@@ -31,7 +31,7 @@ export interface ImageCropperDialogProps {
* the source is PNG/GIF/WebP/AVIF, JPEG otherwise. Override to force
* a specific format. */
outputFormat?: 'auto' | 'jpeg' | 'png';
/** Async upload handler receives the cropped Blob. Cropper closes on
/** Async upload handler - receives the cropped Blob. Cropper closes on
* success; toasts on error. */
onUpload: (blob: Blob) => Promise<void>;
/** Dialog title. Default "Crop image". */
@@ -41,7 +41,7 @@ export interface ImageCropperDialogProps {
/**
* MIME types that carry an alpha channel. A PNG source dropped through a
* JPEG-output canvas composites transparent pixels against black on
* export destroying the design intent of any logo with transparency.
* export - destroying the design intent of any logo with transparency.
* Default to PNG output whenever the source could have had alpha.
*/
const ALPHA_CAPABLE_MIME = new Set(['image/png', 'image/gif', 'image/webp', 'image/avif']);
@@ -51,7 +51,7 @@ const ALPHA_CAPABLE_MIME = new Set(['image/png', 'image/gif', 'image/webp', 'ima
* frame over the picked file, then writes the cropped pixels to a
* Canvas, exports as JPEG, and hands the Blob to the caller.
*
* Used for: profile avatars, port logos, brochure covers anywhere a
* Used for: profile avatars, port logos, brochure covers - anywhere a
* square or fixed-aspect image needs to land in storage.
*/
export function ImageCropperDialog({

View File

@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { CountryFlag } from '@/components/shared/country-flag';
import { toastError } from '@/lib/api/toast-error';
import { getCountryName, type CountryCode } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
@@ -18,8 +19,8 @@ interface InlineCountryFieldProps {
}
/**
* Click-to-edit country picker. Renders the localized country name with a
* regional-indicator flag glyph; opens a CountryCombobox on click.
* Click-to-edit country picker. Renders the localized country name with an
* SVG flag (via `<CountryFlag>`); opens a CountryCombobox on click.
*/
export function InlineCountryField({
value,
@@ -77,8 +78,8 @@ export function InlineCountryField({
);
}
const display = value
? `${flagEmoji(value)} ${getCountryName(value, typeof navigator !== 'undefined' ? navigator.language : 'en')}`
const countryName = value
? getCountryName(value, typeof navigator !== 'undefined' ? navigator.language : 'en')
: null;
return (
@@ -94,9 +95,14 @@ export function InlineCountryField({
className,
)}
>
<span className={cn('flex-1', !display && 'text-muted-foreground')}>
{display ?? emptyText}
</span>
{value ? (
<span className="flex flex-1 items-center gap-1.5">
<CountryFlag code={value} className="h-3 w-4" decorative />
<span>{countryName}</span>
</span>
) : (
<span className="flex-1 text-muted-foreground">{emptyText}</span>
)}
{!disabled && (
<Pencil
className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50"
@@ -106,10 +112,3 @@ export function InlineCountryField({
</button>
);
}
function flagEmoji(code: string): string {
if (code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
}

View File

@@ -117,7 +117,7 @@ function InlineEditableFieldBody(props: InlineEditableFieldProps) {
}
if (props.variant === 'select') {
// Picker fields don't need a read/edit mode toggle a Select is
// Picker fields don't need a read/edit mode toggle - a Select is
// already a click-to-open control. Rendering one consistent
// SelectTrigger eliminates the width jump that happened when we
// swapped from a content-sized ReadButton to a w-full SelectTrigger

View File

@@ -27,7 +27,7 @@ export interface InlineTagEditorProps {
readOnly?: boolean;
/** Optional section heading rendered above the chips. When supplied and
* there are no tags configured port-wide AND none currently applied,
* the entire block (heading + editor) hides keeps detail pages clean
* the entire block (heading + editor) hides - keeps detail pages clean
* for ports that haven't set up tagging. */
heading?: string;
/** Optional wrapper class applied around heading + editor. */
@@ -47,7 +47,7 @@ export function InlineTagEditor({
const [search, setSearch] = useState('');
// Always fetch so we can hide the editor entirely when no tags are
// configured AND the entity has no tags already applied keeps the
// configured AND the entity has no tags already applied - keeps the
// detail page clean for ports that haven't set up tagging yet. The
// list is cheap, port-scoped, and cached for a minute.
const { data: allTags } = useQuery<{ data: Tag[] }>({
@@ -62,7 +62,7 @@ export function InlineTagEditor({
onError: (err) => toastError(err),
});
// F16: inline "Create new tag: X" was missing entirely. Reps had to
// F16: inline "Create new tag: X" - was missing entirely. Reps had to
// context-switch to Admin → Tags to create the tag, then come back.
const createTag = useMutation({
mutationFn: (name: string) =>

View File

@@ -37,7 +37,7 @@ interface InterestPickerProps {
/**
* Searchable interest picker. Mirrors ClientPicker. When `clientId` is
* provided the dropdown scopes to that client so picking the client
* provided the dropdown scopes to that client - so picking the client
* first naturally narrows the interest options.
*/
export function InterestPicker({
@@ -84,7 +84,7 @@ export function InterestPicker({
return (
// `modal` is required when this picker is rendered inside a Sheet /
// Dialog without it the CommandInput stays focus-blocked by the
// Dialog - without it the CommandInput stays focus-blocked by the
// outer Sheet's focus trap and clicks/typing are silently dropped.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>

View File

@@ -33,7 +33,7 @@ interface Note {
sourceId?: string;
sourceLabel?: string;
/** Pipeline stage the linked interest was at when the note was authored.
* Only populated for interest notes drives the small stage chip. */
* Only populated for interest notes - drives the small stage chip. */
pipelineStageAtCreation?: string | null;
}
@@ -55,7 +55,7 @@ const SELF_SOURCE: Record<NotesEntityType, NoteSource | null> = {
companies: 'company',
residential_clients: 'residential_client',
// Aggregate-mode is only meaningful for the entities above. Interests
// and residential_interests are leaf nodes there's nothing to roll
// and residential_interests are leaf nodes - there's nothing to roll
// up to them.
interests: null,
residential_interests: null,
@@ -94,7 +94,7 @@ interface NotesListProps {
* Aggregate-on-read: union the entity's own notes with notes from
* related entities (interests, owned yachts / company yachts, owner
* client). Cross-source notes render with a source chip and are
* read-only here open the source entity's page to edit.
* read-only here - open the source entity's page to edit.
*
* Supported for entityType in {clients, yachts, companies,
* residential_clients}. Ignored for interests / residential_interests.
@@ -167,7 +167,7 @@ export function NotesList({
const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
// Smooth animation when notes are added / edited / deleted replaces
// Smooth animation when notes are added / edited / deleted - replaces
// the abrupt re-render with a per-row fade/slide.
const [animateRef] = useAutoAnimate<HTMLDivElement>();
@@ -177,7 +177,7 @@ export function NotesList({
});
// Mutations always target the parent entity (client). Aggregated
// notes from interests/yachts are read-only here the rep edits
// notes from interests/yachts are read-only here - the rep edits
// them on the source entity's page (we surface a "Open source" link
// below). Keeping mutations against `baseEndpoint` keeps the POST
// route handler clean.
@@ -250,7 +250,7 @@ export function NotesList({
</div>
</div>
{/* Aggregated-mode controls sort toggle. Only renders when
{/* Aggregated-mode controls - sort toggle. Only renders when
* aggregation is on and there's actually content to group. */}
{aggregateOn && notes.length > 0 && (
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">

View File

@@ -116,7 +116,7 @@ export function OwnerPicker({
<span className="truncate flex items-center gap-2">
{/* A20: surface the dual-mode (Client/Company) hint even when
* no value is picked yet, so users know the trigger opens a
* two-tab picker pre-fix the toggle was hidden until the
* two-tab picker - pre-fix the toggle was hidden until the
* popover was open, making the form read as client-only. */}
{value ? (
<span className="text-xs opacity-60">

View File

@@ -64,7 +64,7 @@ export function RealtimeToasts() {
const isWon = payload.outcome === 'won';
const label = formatOutcome(payload.outcome) ?? payload.outcome;
const fn = isWon ? toast.success : toast.message;
fn(`Interest closed ${label}`);
fn(`Interest closed - ${label}`);
}
socket.on('interest:stageChanged', onStageChanged);

View File

@@ -27,7 +27,7 @@ interface SaveViewDialogProps {
* "Save current view" affordance can live inside the column picker
* (where the rep is already configuring the table) instead of as a
* top-level button. SavedViewsDropdown now only handles browsing and
* applying existing views the save and read concerns are split.
* applying existing views - the save and read concerns are split.
*/
export function SaveViewDialog({
open,

View File

@@ -22,7 +22,7 @@ interface SavedViewsDropdownProps {
/**
* Read-only browser for saved views. The "Save current view" affordance
* has moved into the ColumnPicker menu (see SaveViewDialog). This
* component renders nothing when the user has no saved views the
* component renders nothing when the user has no saved views - the
* Bookmark button on its own is just visual noise until something has
* been saved.
*/

View File

@@ -4,8 +4,8 @@
* Shared send-document dialog (Phase 7).
*
* Used by:
* - {@link SendBerthPdfDialog} (berths/) single berth, recipient picker.
* - {@link SendBrochureDialog} (clients/, interests/) brochure picker.
* - {@link SendBerthPdfDialog} (berths/) - single berth, recipient picker.
* - {@link SendBrochureDialog} (clients/, interests/) - brochure picker.
* - The interest "send from interest" pattern reuses both via a wrapper.
*
* §14.7 mitigations enforced client-side:
@@ -51,7 +51,7 @@ interface SendDocumentDialogProps {
context: { berthId?: string; brochureId?: string };
/** Title displayed in the dialog header. */
title: string;
/** Short context line under the title (e.g. "Berth A1 primary version"). */
/** Short context line under the title (e.g. "Berth A1 - primary version"). */
subtitle?: string;
onSent?: () => void;
}
@@ -190,7 +190,7 @@ function SendDocumentDialogInner({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{subtitle && <DialogDescription>{subtitle}</DialogDescription>}
@@ -224,7 +224,7 @@ function SendDocumentDialogInner({
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="ds-body">Message body</Label>
{/* Tracked-link composer appends a trackable redirect
{/* Tracked-link composer - appends a trackable redirect
URL to the body so click-throughs reconcile back to
the send's analytics. */}
<TrackedLinkComposerButton

View File

@@ -38,7 +38,7 @@ export function TagPicker({
const tagOptions = options as Array<{ value: string; label: string; color?: string }>;
// If the port has no tags configured AND the rep also hasn't selected any
// (e.g. a tag was deleted after selection), don't render the picker at all
// (e.g. a tag was deleted after selection), don't render the picker at all -
// the affordance is noise until tags are set up under Admin → Tags.
if (!isLoading && tagOptions.length === 0 && selectedIds.length === 0) {
return null;

View File

@@ -51,7 +51,7 @@ export function UserPicker({
queryKey: ['user-options'],
queryFn: () => apiFetch('/api/v1/admin/users/options'),
staleTime: 5 * 60_000,
// Don't fetch until the popover opens keeps the page light when
// Don't fetch until the popover opens - keeps the page light when
// most reps never expand this field.
enabled: open,
});

View File

@@ -10,7 +10,7 @@ const VITALS_ENDPOINT = '/api/v1/internal/vitals';
* any perf-optimisation work. Sends via `sendBeacon` (survives unload)
* with a `fetch` fallback for browsers that don't support beacon.
*
* Mounted in the dashboard layout fires once per page lifecycle for
* Mounted in the dashboard layout - fires once per page lifecycle for
* each metric. Vitals are reported when stable (LCP locked in, INP after
* meaningful interaction, etc.), not on every render.
*/
@@ -41,7 +41,7 @@ export function WebVitalsReporter() {
body,
keepalive: true,
}).catch(() => {
// Swallow vitals are best-effort.
// Swallow - vitals are best-effort.
});
}