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:
@@ -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'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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user