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

@@ -79,7 +79,7 @@ const BUCKETS: BucketConfig[] = [
{ type: 'stages', label: 'Stages', icon: TrendingUp },
// Notes are noisy content search.
{ type: 'notes', label: 'Notes', icon: MessageSquare },
// Navigation (settings pages + admin sub-cards) lives at the very bottom
// Navigation (settings pages + admin sub-cards) lives at the very bottom -
// users open the search to find entity records first; pages/settings are
// the long-tail jump targets.
{ type: 'navigation', label: 'Settings', icon: SettingsIcon },
@@ -119,7 +119,7 @@ export function CommandSearch() {
const { results, isFetching, recentSearches, recentlyViewed } = useSearch(query, {
type: activeBucket === 'all' ? undefined : activeBucket,
// Slightly higher cap when narrowed to one bucket gives the user
// Slightly higher cap when narrowed to one bucket - gives the user
// room to scan more matches without paging out to /search.
limit: activeBucket === 'all' ? 5 : 15,
});
@@ -127,9 +127,9 @@ export function CommandSearch() {
// Persist the totals from the last "all" query so the filter chips stay
// populated when the user narrows to a single bucket. Without this, the
// narrowed query only returns counts for the active bucket and every
// other chip would vanish making it impossible to swap between
// other chip would vanish - making it impossible to swap between
// filters without clearing back to "All" first. Backed by state (not
// a ref) so render reads are pure React Compiler-safe.
// a ref) so render reads are pure - React Compiler-safe.
const [lastAllTotals, setLastAllTotals] = useState<SearchResults['totals'] | null>(null);
useEffect(() => {
if (activeBucket === 'all' && results?.totals) {
@@ -208,7 +208,7 @@ export function CommandSearch() {
closeAndNavigate(res.href);
}
} catch {
// Best-effort fall through to normal search.
// Best-effort - fall through to normal search.
}
},
[closeAndNavigate],
@@ -229,7 +229,7 @@ export function CommandSearch() {
}, [showDropdown, query, results, recentlyViewed, recentSearches, activeBucket, portSlug]);
// Reset focus index when the visible row set changes. The set-state
// is intentional external state (bucket / query) drives focus.
// is intentional - external state (bucket / query) drives focus.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFocusIndex(-1);
@@ -263,7 +263,7 @@ export function CommandSearch() {
}
closeAndNavigate(row.href);
}
// Enter without a focused row is a no-op the dropdown is already
// Enter without a focused row is a no-op - the dropdown is already
// showing every relevant match (filter chips raise the cap when the
// user wants to see more of one bucket). No standalone /search
// page; refining the query is faster than scrolling further.
@@ -326,7 +326,7 @@ export function CommandSearch() {
'max-lg:fixed max-lg:inset-x-2 max-lg:top-16 max-lg:bottom-2 max-lg:max-w-none',
)}
>
{/* Filter chip row always visible while the dropdown is open. */}
{/* Filter chip row - always visible while the dropdown is open. */}
<FilterChipRow
totals={chipTotals}
active={activeBucket}
@@ -335,7 +335,7 @@ export function CommandSearch() {
/>
<div className="max-h-[60vh] max-lg:max-h-[calc(100%-3rem)] overflow-y-auto py-1">
{/* No query yet recently viewed + recent terms. */}
{/* No query yet - recently viewed + recent terms. */}
{query.length < 2 && (
<EmptyStateBeforeSearch
listboxId={listboxId}
@@ -348,7 +348,7 @@ export function CommandSearch() {
/>
)}
{/* Active query results or zero-state. */}
{/* Active query - results or zero-state. */}
{query.length >= 2 && (
<ResultsRegion
listboxId={listboxId}
@@ -363,7 +363,7 @@ export function CommandSearch() {
)}
</div>
{/* Footer keyboard hint only. */}
{/* Footer - keyboard hint only. */}
{query.length >= 2 && (
<div className="border-t bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
navigate · open · esc close
@@ -408,7 +408,7 @@ function FilterChipRow({
{BUCKETS.map((b) => {
const count = totals?.[b.type] ?? 0;
// Hide chips for buckets with zero matches in the last "all"
// snapshot keeps the row tight and avoids dead-end clicks.
// snapshot - keeps the row tight and avoids dead-end clicks.
// Always show the active chip + every chip before a query has run.
if (!disabled && count === 0 && active !== b.type) return null;
return (
@@ -1145,7 +1145,7 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
});
}
}
// Notes content matches inside free-text notes are noisy by nature, so
// Notes - content matches inside free-text notes are noisy by nature, so
// the user sees them after the entity-specific buckets above have
// surfaced their tighter matches.
if (include('notes')) {
@@ -1169,7 +1169,7 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
});
}
}
// Navigation (settings pages + admin section cards) goes LAST these
// Navigation (settings pages + admin section cards) goes LAST - these
// are jump targets, not the primary thing a user opens the search to
// find. Surfacing them after entity matches keeps the top of the
// dropdown focused on records.

View File

@@ -5,7 +5,7 @@ import { Fragment, type ReactNode } from 'react';
* can see why a result matched. Case-insensitive, escapes regex meta-
* chars in the query so a paste of "INV-2025" doesn't blow up.
*
* Tokenized matching splits the query on whitespace and highlights
* Tokenized matching - splits the query on whitespace and highlights
* each token independently, so "joh smi" highlights both "Joh" and
* "Smi" in "John Smith". Mirrors the prefix-tsquery the server uses.
*/

View File

@@ -13,7 +13,7 @@ import { useUIStore } from '@/stores/ui-store';
import { buildFlatRows, type FlatRow } from './command-search';
import { HighlightMatch } from './highlight-match';
// Match the desktop bucket order feels consistent when reps switch contexts.
// Match the desktop bucket order - feels consistent when reps switch contexts.
const BUCKETS: { type: BucketType; label: string }[] = [
{ type: 'clients', label: 'Clients' },
{ type: 'yachts', label: 'Yachts' },
@@ -59,7 +59,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
// The overlay is mounted once at the layout root, so the recently-
// viewed query won't refetch via the usual mount path. Bump it every
// time the drawer opens the user is about to look at it, and the
// time the drawer opens - the user is about to look at it, and the
// staleTime cache may have missed an entity view that happened in a
// route that doesn't render <TrackEntityView>.
useEffect(() => {
@@ -76,7 +76,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
}
const vv = window.visualViewport;
if (!vv) return;
// Subscribing to the visualViewport external store canonical
// Subscribing to the visualViewport external store - canonical
// useEffect+setState shape for that pattern.
const update = () => setVisibleHeight(vv.height);
update();
@@ -112,7 +112,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
// `autoFocus` attribute (synchronous in-gesture, which iOS Safari
// requires before it'll pop the keyboard on programmatic focus).
// A useEffect setTimeout was the previous approach but broke the
// user-gesture chain input was focused, keyboard stayed hidden.
// user-gesture chain - input was focused, keyboard stayed hidden.
// Body scroll lock is delegated to Vaul (modal=true + noBodyStyles=false
// defaults). Manual position:fixed locking caused a visible scroll-then-
@@ -124,7 +124,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
// Reset query when the drawer closes. Without this, reopening the
// overlay would flash stale results before the empty state renders.
// setState in effect is intentional the trigger is a discrete
// setState in effect is intentional - the trigger is a discrete
// open/close transition.
useEffect(() => {
if (!open) {
@@ -162,7 +162,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
navigate(res.href);
}
} catch {
// Best-effort fall through to text search.
// Best-effort - fall through to text search.
}
},
[navigate],
@@ -195,7 +195,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
// - shouldScaleBackground=false: page doesn't shrink behind us;
// feels in-app rather than card-over-page.
// - repositionInputs=false: don't let Vaul jiggle the viewport
// when the input autofocuses and the keyboard appears that
// when the input autofocuses and the keyboard appears - that
// was the source of the "scroll then jump back" we were seeing.
// Vaul still locks scroll via its modal=true default.
// - autoFocus=true: Vaul focuses the input synchronously inside
@@ -212,7 +212,7 @@ export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayP
// `bottom: 0` on position:fixed inconsistently when the
// keyboard is up (sometimes layout viewport, sometimes visual);
// anchoring by top + height removes that ambiguity. Height
// comes from visualViewport.height the only iOS-reliable
// comes from visualViewport.height - the only iOS-reliable
// source for "visible area above keyboard". 12px gap at the
// top keeps a strip of backdrop visible.
style={
@@ -403,7 +403,7 @@ function RowList({
onSelect: (href: string) => void;
variant: 'empty' | 'results';
}) {
// Split rows by section header "Recently viewed", "Recent searches",
// Split rows by section header - "Recently viewed", "Recent searches",
// "Results". Headers live inside the row list so they scroll with their
// content (instead of sticky-positioning, which adds visual noise).
const recentViews = rows.filter((r) => r.kind === 'recent-view');
@@ -437,7 +437,7 @@ function RowList({
type="button"
onClick={() => {
// Recent-term taps populate the input rather than
// navigating the rep usually wants to refine, not
// navigating - the rep usually wants to refine, not
// jump straight back to the previous result.
const input = document.querySelector<HTMLInputElement>(
'input[aria-label="Search"]',
@@ -469,7 +469,7 @@ function RowList({
* Walk the flat result rows, inserting a small section header above the
* first row of each bucket so reps know exactly what kind of entity
* each result points to ("CLIENTS", "INTERESTS", "BERTHS", …). Bucket
* order follows `buildFlatRows`'s ordering most-likely matches first.
* order follows `buildFlatRows`'s ordering - most-likely matches first.
*/
function renderResultRows(
rows: FlatRow[],

View File

@@ -6,7 +6,7 @@ import { useTrackEntityView } from '@/hooks/use-track-entity-view';
* Render-only client component that records "the user opened this
* entity" via the global-search recently-viewed Redis log. Drop into a
* server-rendered detail page as `<TrackEntityView type="client" id={…} />`
* the component renders nothing.
* - the component renders nothing.
*
* Centralises the tracking call so future changes (debounce, schema,
* batching) only need to touch one file rather than every detail page.