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

@@ -19,19 +19,76 @@ interface CurrencyInputProps extends Omit<
className?: string;
}
const groupFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
useGrouping: true,
});
function formatGrouped(value: number | string): string {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return '';
return groupFormatter.format(n);
}
function parseTyped(raw: string): { display: string; numeric: number | null } {
// Strip everything except digits, '.', '-'. Commas are formatting noise from
// our own display and are removed before re-grouping. (Locale note: this
// assumes '.' as decimal separator, matching the en-US formatter below.)
let cleaned = raw.replace(/[^\d.-]/g, '');
// Keep only the first '.' (additional dots are dropped).
const firstDot = cleaned.indexOf('.');
if (firstDot !== -1) {
cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '');
}
// Sign: only honour a leading '-'; strip any others.
const negative = cleaned.startsWith('-');
cleaned = (negative ? '-' : '') + cleaned.replace(/-/g, '');
if (cleaned === '' || cleaned === '-') return { display: cleaned, numeric: null };
const dot = cleaned.indexOf('.');
const intPart = dot === -1 ? cleaned : cleaned.slice(0, dot);
const fracPart = dot === -1 ? null : cleaned.slice(dot + 1);
const intDigitsOnly = intPart.replace('-', '');
const intNumeric = intDigitsOnly === '' ? 0 : Number(intDigitsOnly);
const numeric = (negative ? -1 : 1) * (intNumeric + (fracPart ? Number(`0.${fracPart}`) || 0 : 0));
const intDisplay =
intDigitsOnly === ''
? (negative ? '-' : '')
: (negative ? '-' : '') + groupFormatter.format(intNumeric);
const display = fracPart === null ? intDisplay : `${intDisplay}.${fracPart}`;
return { display, numeric: Number.isFinite(numeric) ? numeric : null };
}
/**
* Numeric input pre-decorated with a currency symbol. The display
* value is the raw number the user typed (we don't fight the keystroke
* cadence by re-formatting on every key) — formatted display lives in
* read-only contexts via `formatCurrency()`. This keeps form behaviour
* predictable while still scoping the input to a money field via the
* symbol prefix and the `decimal` inputMode.
* Numeric input pre-decorated with a currency symbol and thousand-separator
* grouping (e.g. `3,528,000.50`). Uses `type="text"` + `inputMode="decimal"`
* so we can render commas (HTML `type="number"` strips them) while still
* surfacing the decimal keypad on iOS/Android. The parent receives a raw
* number via `onChange`; the formatted string is local UI state.
*/
export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
({ value, onChange, currency = 'USD', className, ...props }, ref) => {
({ value, onChange, currency = 'USD', className, onBlur, onFocus, ...props }, ref) => {
const symbol = currencySymbol(currency);
const display = value === null || value === undefined || value === '' ? '' : String(value);
const [display, setDisplay] = React.useState<string>(() =>
value === null || value === undefined || value === '' ? '' : formatGrouped(value),
);
const focusedRef = React.useRef(false);
// Re-sync the display when the controlled value changes externally (form
// reset, parent-driven update). Skip while the input is focused so we
// don't fight the user's keystrokes.
React.useEffect(() => {
if (focusedRef.current) return;
if (value === null || value === undefined || value === '') {
setDisplay('');
} else {
setDisplay(formatGrouped(value));
}
}, [value]);
return (
<div className="relative">
@@ -43,19 +100,29 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
</span>
<Input
ref={ref}
type="number"
type="text"
inputMode="decimal"
step="0.01"
min="0"
autoComplete="off"
value={display}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
onChange(null);
return;
const { display: nextDisplay, numeric } = parseTyped(e.target.value);
setDisplay(nextDisplay);
onChange(numeric);
}}
onFocus={(e) => {
focusedRef.current = true;
onFocus?.(e);
}}
onBlur={(e) => {
focusedRef.current = false;
// On blur, canonicalize to a clean grouped representation so the
// user sees the final value rather than any half-typed state.
if (value === null || value === undefined || value === '') {
setDisplay('');
} else {
setDisplay(formatGrouped(value));
}
const n = Number(raw);
onChange(Number.isFinite(n) ? n : null);
onBlur?.(e);
}}
className={cn('pl-9 tabular-nums', className)}
{...props}