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

@@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -18,6 +18,16 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Command,
@@ -30,12 +40,13 @@ import {
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options';
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants';
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
import { cn } from '@/lib/utils';
const CATEGORY_LABELS: Record<string, string> = {
@@ -77,14 +88,14 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const [clientOpen, setClientOpen] = useState(false);
const [berthOpen, setBerthOpen] = useState(false);
const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft');
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
formState: { errors, isSubmitting, isDirty },
} = useForm<CreateInterestInput>({
resolver: zodResolver(createInterestSchema),
defaultValues: {
@@ -102,6 +113,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedBerthId = watch('berthId');
const selectedYachtId = watch('yachtId');
const [createYachtOpen, setCreateYachtOpen] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
function requestClose() {
if (isDirty && !isSubmitting && !mutation.isPending) {
setDiscardConfirmOpen(true);
return;
}
onOpenChange(false);
}
// Fetch the selected client's company memberships so the YachtPicker can
// include yachts owned by companies the client belongs to (e.g. a
@@ -200,7 +220,16 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Sheet
open={open}
onOpenChange={(next) => {
if (next) {
onOpenChange(true);
return;
}
requestClose();
}}
>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
@@ -215,7 +244,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1">
<Label>Client *</Label>
<Popover open={clientOpen} onOpenChange={setClientOpen}>
<Popover open={clientOpen} onOpenChange={setClientOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -231,8 +260,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
{/* shouldFilter={false}: server-side search via setClientSearch
drives the result set. Without this, cmdk's default filter
matches the user's typed text against CommandItem.value
(the client UUID) and silently drops every result that
doesn't contain the typed substring in its id. */}
<Command shouldFilter={false}>
<CommandInput placeholder="Search clients..." onValueChange={setClientSearch} />
<CommandList>
<CommandEmpty>
@@ -269,7 +303,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1">
<Label>Berth (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen}>
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -284,8 +318,8 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
<Command shouldFilter={false}>
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
<CommandList>
<CommandEmpty>
@@ -431,10 +465,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="referral">Referral</SelectItem>
<SelectItem value="broker">Broker</SelectItem>
{SOURCES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -444,48 +479,43 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Separator />
{/* Desired berth dimensions (recommender inputs) */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Berth size desired
</h3>
<p className="text-xs text-muted-foreground">
Imperial. Optional - the recommender treats blank fields as no constraint on that
axis.
</p>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label htmlFor="desiredLengthFt">Length (ft)</Label>
<Input
id="desiredLengthFt"
{...register('desiredLengthFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 60"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desiredWidthFt">Width (ft)</Label>
<Input
id="desiredWidthFt"
{...register('desiredWidthFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 18"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desiredDraftFt">Draft (ft)</Label>
<Input
id="desiredDraftFt"
{...register('desiredDraftFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 6"
/>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Berth size desired
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Optional - the recommender treats blank fields as no constraint on that axis.
</p>
</div>
<UnitToggle value={desiredUnit} onChange={setDesiredUnit} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DimensionInput
htmlId="desiredLengthFt"
label="Length"
placeholder={desiredUnit === 'ft' ? 'e.g. 60' : 'e.g. 18.29'}
unit={desiredUnit}
ftValue={watch('desiredLengthFt') as string | undefined}
onChangeFt={(v) => setValue('desiredLengthFt', v, { shouldDirty: true })}
/>
<DimensionInput
htmlId="desiredWidthFt"
label="Width"
placeholder={desiredUnit === 'ft' ? 'e.g. 18' : 'e.g. 5.49'}
unit={desiredUnit}
ftValue={watch('desiredWidthFt') as string | undefined}
onChangeFt={(v) => setValue('desiredWidthFt', v, { shouldDirty: true })}
/>
<DimensionInput
htmlId="desiredDraftFt"
label="Draft"
placeholder={desiredUnit === 'ft' ? 'e.g. 6' : 'e.g. 1.83'}
unit={desiredUnit}
ftValue={watch('desiredDraftFt') as string | undefined}
onChangeFt={(v) => setValue('desiredDraftFt', v, { shouldDirty: true })}
/>
</div>
</div>
@@ -506,12 +536,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
{reminderEnabled && (
<div className="space-y-1">
<Label>Reminder Days</Label>
<Input
{...register('reminderDays', { valueAsNumber: true })}
type="number"
min={1}
placeholder="e.g. 7"
<Label htmlFor="reminderDays">Reminder cadence</Label>
<ReminderDaysInput
id="reminderDays"
value={watch('reminderDays') ?? null}
onChange={(v) => setValue('reminderDays', v ?? undefined)}
/>
</div>
)}
@@ -526,7 +555,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button type="button" variant="outline" onClick={requestClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
@@ -537,6 +566,29 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Button>
</SheetFooter>
</form>
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;ve filled in some fields. Closing now will lose them.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDiscardConfirmOpen(false);
onOpenChange(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
>
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SheetContent>
{createYachtOpen && selectedClientId && (
<YachtForm
@@ -548,3 +600,140 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Sheet>
);
}
// ── Helpers for the "Berth size desired" section ──────────────────────────────
const FT_PER_M = 1 / 0.3048;
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) {
return (
<div
className="inline-flex rounded-md border bg-muted/30 p-0.5 text-xs"
role="radiogroup"
aria-label="Display unit"
>
{(['ft', 'm'] as const).map((u) => (
<button
key={u}
type="button"
role="radio"
aria-checked={value === u}
onClick={() => onChange(u)}
className={cn(
'h-7 rounded px-3 font-medium transition-colors',
value === u
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{u}
</button>
))}
</div>
);
}
interface DimensionInputProps {
htmlId: string;
label: string;
placeholder?: string;
unit: 'ft' | 'm';
ftValue: string | number | undefined;
onChangeFt: (next: string | undefined) => void;
}
/**
* Single dimension input bound to a form value stored in feet. Renders the
* value in the rep's chosen display unit and converts back on edit. The form
* state stays canonical ft so the recommender (which queries `b.length_ft`
* etc.) sees the same number regardless of which unit the rep typed in.
*
* Local `display` state preserves mid-typing strings like "18." that would
* otherwise be lost to round-tripping through Number().
*/
function DimensionInput({
htmlId,
label,
placeholder,
unit,
ftValue,
onChangeFt,
}: DimensionInputProps) {
const focusedRef = useRef(false);
const [display, setDisplay] = useState<string>(() => computeDisplay(ftValue, unit));
// Re-sync from the canonical ft value when it changes externally (form
// reset, unit toggle). Skip while focused so we don't fight keystrokes.
useEffect(() => {
if (focusedRef.current) return;
setDisplay(computeDisplay(ftValue, unit));
}, [ftValue, unit]);
const altValue = computeAltDisplay(ftValue, unit);
return (
<div className="space-y-1">
<Label htmlFor={htmlId}>
{label} <span className="text-muted-foreground">({unit})</span>
</Label>
<Input
id={htmlId}
type="number"
inputMode="decimal"
step="0.01"
min={0}
placeholder={placeholder}
value={display}
onFocus={() => {
focusedRef.current = true;
}}
onBlur={() => {
focusedRef.current = false;
// Canonicalize the display from the ft source-of-truth on blur so
// any mid-typed garbage clears.
setDisplay(computeDisplay(ftValue, unit));
}}
onChange={(e) => {
const raw = e.target.value;
setDisplay(raw);
if (raw === '') {
onChangeFt(undefined);
return;
}
const n = parseFloat(raw);
if (!Number.isFinite(n) || n <= 0) {
onChangeFt(undefined);
return;
}
const ft = unit === 'ft' ? n : n * FT_PER_M;
onChangeFt(String(round2(ft)));
}}
/>
{altValue ? (
<p className="text-[11px] leading-tight text-muted-foreground"> {altValue}</p>
) : null}
</div>
);
}
function computeDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string {
if (ftValue === undefined || ftValue === null || ftValue === '') return '';
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
if (!Number.isFinite(ft)) return '';
const v = unit === 'ft' ? ft : ft * 0.3048;
return String(round2(v));
}
function computeAltDisplay(
ftValue: string | number | undefined,
unit: 'ft' | 'm',
): string | null {
if (ftValue === undefined || ftValue === null || ftValue === '') return null;
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
if (!Number.isFinite(ft) || ft <= 0) return null;
return unit === 'ft' ? `${round2(ft * 0.3048)} m` : `${round2(ft)} ft`;
}