feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -12,6 +12,7 @@ import {
} from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import {
Anchor,
Bell,
@@ -100,6 +101,7 @@ export function CommandSearch() {
const [focusIndex, setFocusIndex] = useState<number>(-1);
const router = useRouter();
const queryClient = useQueryClient();
const portSlug = useUIStore((s) => s.currentPortSlug);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -113,8 +115,32 @@ export function CommandSearch() {
limit: activeBucket === 'all' ? 5 : 15,
});
// 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
// filters without clearing back to "All" first.
const lastAllTotalsRef = useRef<SearchResults['totals'] | null>(null);
useEffect(() => {
if (activeBucket === 'all' && results?.totals) {
lastAllTotalsRef.current = results.totals;
}
}, [activeBucket, results]);
const chipTotals: SearchResults['totals'] | undefined =
activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals);
const showDropdown = focused;
// CommandSearch lives in the header and persists across navigations,
// so its React Query cache never sees a remount. Invalidate the
// recently-viewed + recent-terms queries whenever the dropdown opens
// so the user sees fresh data after navigating around the app.
useEffect(() => {
if (!showDropdown) return;
queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
}, [showDropdown, queryClient]);
// Cmd/Ctrl+K focuses the input from anywhere on the page.
useEffect(() => {
function onKeyDown(e: globalThis.KeyboardEvent) {
@@ -287,7 +313,7 @@ export function CommandSearch() {
>
{/* Filter chip row — always visible while the dropdown is open. */}
<FilterChipRow
results={results}
totals={chipTotals}
active={activeBucket}
onChange={setActiveBucket}
disabled={query.length < 2}
@@ -337,18 +363,19 @@ export function CommandSearch() {
// ─── Filter chips ────────────────────────────────────────────────────────────
function FilterChipRow({
results,
totals,
active,
onChange,
disabled,
}: {
results: SearchResults | undefined;
/** Counts from the last "all" query, persisted so chips stay visible
* when the user narrows to a single bucket. Falls back to the current
* results.totals when no "all" snapshot exists yet. */
totals: SearchResults['totals'] | undefined;
active: BucketType | 'all';
onChange: (b: BucketType | 'all') => void;
disabled: boolean;
}) {
// Show a chip for every bucket so the user can browse the search
// surface even with no query; counts only render when results exist.
return (
<div
role="tablist"
@@ -364,10 +391,10 @@ function FilterChipRow({
All
</ChipButton>
{BUCKETS.map((b) => {
const count = results?.totals?.[b.type] ?? 0;
// Hide chips for buckets the current user can't see (count === 0
// when the bucket query was permission-skipped) — but only after
// a query has run, otherwise we'd hide every chip on first paint.
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.
// Always show the active chip + every chip before a query has run.
if (!disabled && count === 0 && active !== b.type) return null;
return (
<ChipButton
@@ -701,6 +728,11 @@ function ResultRow({
<HighlightMatch text={row.sub} query={query} />
</div>
)}
{row.relatedVia && (
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
via {row.relatedVia.label}
</div>
)}
</div>
</button>
);
@@ -759,9 +791,9 @@ function BucketSection({
// ─── Flat-row construction (drives keyboard nav + ARIA) ──────────────────────
type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
type FlatRow =
export type FlatRow =
| {
kind: 'recent-view';
key: string;
@@ -783,6 +815,9 @@ type FlatRow =
sub: string | null;
href: string;
badges?: ResultBadge[];
/** Provenance hint when the row was surfaced via graph expansion.
* Rendered as a subtle "via Berth A10" line below the sub. */
relatedVia?: { type: string; label: string } | null;
}
| {
kind: 'other-port';
@@ -791,7 +826,7 @@ type FlatRow =
href: string;
};
interface BuildFlatRowsArgs {
export interface BuildFlatRowsArgs {
query: string;
results: SearchResults | undefined;
recentlyViewed: RecentlyViewedItem[];
@@ -800,7 +835,7 @@ interface BuildFlatRowsArgs {
portSlug: string | null;
}
function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args;
const rows: FlatRow[] = [];
@@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub: c.matchedContact ?? null,
href: `/${portSlug}/clients/${c.id}`,
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
relatedVia: c.relatedVia ?? null,
});
}
}
@@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
label: y.name,
sub,
href: `/${portSlug}/yachts/${y.id}`,
relatedVia: y.relatedVia ?? null,
});
}
}
@@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
label: co.name,
sub,
href: `/${portSlug}/companies/${co.id}`,
relatedVia: co.relatedVia ?? null,
});
}
}
@@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub: i.berthMooringNumber,
href: `/${portSlug}/interests/${i.id}`,
badges: badges.length > 0 ? badges : undefined,
relatedVia: i.relatedVia ?? null,
});
}
}
@@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub,
href: `/${portSlug}/berths/${b.id}`,
badges: badges.length > 0 ? badges : undefined,
relatedVia: b.relatedVia ?? null,
});
}
}