Files
pn-new-crm/src/components/interests/interest-form.tsx
Matt 6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00

760 lines
28 KiB
TypeScript

'use client';
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';
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
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,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
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 type { z } from 'zod';
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
import { cn } from '@/lib/utils';
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General interest',
specific_qualified: 'Specific qualified',
hot_lead: 'Hot lead',
};
interface InterestFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Pre-fill clientId when launching the form from a client detail page.
* Ignored when `interest` is provided (edit mode).
*/
defaultClientId?: string;
interest?: {
id: string;
clientId: string;
clientName?: string | null;
yachtId?: string | null;
berthId?: string | null;
berthMooringNumber?: string | null;
pipelineStage: string;
leadCategory?: string | null;
source?: string | null;
reminderEnabled?: boolean;
reminderDays?: number | null;
tags?: Array<{ id: string }>;
desiredLengthFt?: string | number | null;
desiredWidthFt?: string | number | null;
desiredDraftFt?: string | number | null;
};
}
export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) {
const queryClient = useQueryClient();
const isEdit = !!interest;
const [clientOpen, setClientOpen] = useState(false);
const [berthOpen, setBerthOpen] = useState(false);
const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft');
const {
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<z.input<typeof createInterestSchema>, unknown, CreateInterestInput>({
resolver: zodResolver(createInterestSchema),
defaultValues: {
clientId: '',
yachtId: undefined,
pipelineStage: 'enquiry',
reminderEnabled: false,
tagIds: [],
},
});
const tagIds = watch('tagIds') ?? [];
const reminderEnabled = watch('reminderEnabled');
const selectedClientId = watch('clientId');
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
// managing-director client whose yachts are titled to the company).
const { data: clientDetail } = useQuery<{
data: { companies?: Array<{ company: { id: string } }> };
}>({
queryKey: ['client-detail-for-interest-form', selectedClientId],
queryFn: () => apiFetch(`/api/v1/clients/${selectedClientId}`),
enabled: !!selectedClientId,
});
const memberCompanyIds: string[] = clientDetail?.data.companies?.map((m) => m.company.id) ?? [];
const yachtOwnerFilter = selectedClientId
? [
{ type: 'client' as const, id: selectedClientId },
...memberCompanyIds.map((id) => ({ type: 'company' as const, id })),
]
: undefined;
const {
options: clientOptions,
isLoading: clientsLoading,
setSearch: setClientSearch,
} = useEntityOptions({
endpoint: '/api/v1/clients/options',
labelKey: 'fullName',
});
const {
options: berthOptions,
isLoading: berthsLoading,
setSearch: setBerthSearch,
} = useEntityOptions({
endpoint: '/api/v1/berths/options',
labelKey: 'mooringNumber',
});
useEffect(() => {
if (interest && open) {
reset({
clientId: interest.clientId,
yachtId: interest.yachtId ?? undefined,
berthId: interest.berthId ?? undefined,
pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined,
source: interest.source ?? undefined,
reminderEnabled: interest.reminderEnabled ?? false,
reminderDays: interest.reminderDays ?? undefined,
tagIds: interest.tags?.map((t) => t.id) ?? [],
desiredLengthFt:
interest.desiredLengthFt === null || interest.desiredLengthFt === undefined
? undefined
: String(interest.desiredLengthFt),
desiredWidthFt:
interest.desiredWidthFt === null || interest.desiredWidthFt === undefined
? undefined
: String(interest.desiredWidthFt),
desiredDraftFt:
interest.desiredDraftFt === null || interest.desiredDraftFt === undefined
? undefined
: String(interest.desiredDraftFt),
});
} else if (!interest && open) {
reset({
clientId: defaultClientId ?? '',
yachtId: undefined,
pipelineStage: 'enquiry',
reminderEnabled: false,
tagIds: [],
});
}
}, [interest, defaultClientId, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateInterestInput) => {
// Enrich with the dual-store ft+m values + the entry-unit. The form
// tracks the canonical ft via DimensionInput; we compute the matching
// m value for the API and stamp the unit so a future edit can render
// the rep's literal entry without conversion drift.
const enriched: CreateInterestInput = {
...data,
desiredLengthM: ftToMStr(data.desiredLengthFt),
desiredWidthM: ftToMStr(data.desiredWidthFt),
desiredDraftM: ftToMStr(data.desiredDraftFt),
desiredLengthUnit: desiredUnit,
desiredWidthUnit: desiredUnit,
desiredDraftUnit: desiredUnit,
};
if (isEdit) {
const { tagIds: tIds, ...rest } = enriched;
await apiFetch(`/api/v1/interests/${interest!.id}`, { method: 'PATCH', body: rest });
if (tIds) {
await apiFetch(`/api/v1/interests/${interest!.id}/tags`, {
method: 'PUT',
body: { tagIds: tIds },
});
}
} else {
await apiFetch('/api/v1/interests', { method: 'POST', body: enriched });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
onOpenChange(false);
},
});
function ftToMStr(ft: string | number | undefined | null): string | undefined {
if (ft === undefined || ft === null || ft === '') return undefined;
const n = typeof ft === 'number' ? ft : Number(ft);
if (!Number.isFinite(n) || n <= 0) return undefined;
return String(Math.round(n * 0.3048 * 100) / 100);
}
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
return (
<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>
</SheetHeader>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
{/* Client */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Client & Berth
</h3>
<div className="space-y-1">
<Label>Client *</Label>
<Popover open={clientOpen} onOpenChange={setClientOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={clientOpen}
className={cn(
'w-full justify-between',
!selectedClientId && 'text-muted-foreground',
)}
disabled={isEdit}
>
{selectedClient?.label ?? interest?.clientName ?? 'Select client...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-(--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>
{clientsLoading ? 'Loading...' : 'No clients found.'}
</CommandEmpty>
<CommandGroup>
{clientOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
setValue('clientId', val);
setClientOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedClientId === option.value ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{errors.clientId && (
<p className="text-xs text-destructive">{errors.clientId.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Berth (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={berthOpen}
className={cn(
'w-full justify-between',
!selectedBerthId && 'text-muted-foreground',
)}
>
{selectedBerth?.label ?? interest?.berthMooringNumber ?? 'Select berth...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popper-anchor-width) min-w-[280px] p-0">
<Command shouldFilter={false}>
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
<CommandList>
<CommandEmpty>
{berthsLoading ? 'Loading...' : 'No berths found.'}
</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
setValue('berthId', undefined);
setBerthOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
!selectedBerthId ? 'opacity-100' : 'opacity-0',
)}
/>
None
</CommandItem>
{berthOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
setValue('berthId', val);
setBerthOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedBerthId === option.value ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
{selectedClientId && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setCreateYachtOpen(true)}
>
<Plus className="mr-1 h-3 w-3" aria-hidden />
Add new
</Button>
)}
</div>
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
<p className="text-xs text-muted-foreground">
Required before the interest can leave the &quot;Open&quot; stage.
{memberCompanyIds.length > 0 && (
<>
{' '}
Includes yachts from {memberCompanyIds.length}{' '}
{memberCompanyIds.length === 1 ? 'member company' : 'member companies'}.
</>
)}
</p>
</div>
</div>
<Separator />
{/* Pipeline */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Pipeline
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Stage</Label>
<Select
value={watch('pipelineStage') ?? 'open'}
onValueChange={(v) =>
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number])
}
>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
{PIPELINE_STAGES.map((s) => (
<SelectItem key={s} value={s}>
{STAGE_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Lead Category</Label>
<Select
value={watch('leadCategory') ?? ''}
onValueChange={(v) =>
setValue(
'leadCategory',
v ? (v as (typeof LEAD_CATEGORIES)[number]) : undefined,
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{LEAD_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c] ?? c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Source</Label>
<Select
value={watch('source') ?? ''}
onValueChange={(v) => setValue('source', v || undefined)}
>
<SelectTrigger>
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
{SOURCES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
{/* Desired berth dimensions (recommender inputs) */}
<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>
<Separator />
{/* Reminder */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Reminder
</h3>
<div className="flex items-center gap-2">
<Checkbox
id="reminderEnabled"
checked={reminderEnabled ?? false}
onCheckedChange={(v) => setValue('reminderEnabled', !!v)}
/>
<Label htmlFor="reminderEnabled">Enable reminder</Label>
</div>
{reminderEnabled && (
<div className="space-y-1">
<Label htmlFor="reminderDays">Reminder cadence</Label>
<ReminderDaysInput
id="reminderDays"
value={watch('reminderDays') ?? null}
onChange={(v) => setValue('reminderDays', v ?? undefined)}
/>
</div>
)}
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={requestClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
)}
{isEdit ? 'Save changes' : 'Create 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
open={createYachtOpen}
onOpenChange={setCreateYachtOpen}
initialOwner={{ type: 'client', id: selectedClientId }}
/>
)}
</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`;
}