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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
117
src/components/shared/country-flag.tsx
Normal file
117
src/components/shared/country-flag.tsx
Normal 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 ~1–2 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user