Ran the official @tailwindcss/upgrade tool: - tailwind.config.ts → @theme directive in globals.css - @tailwind base/components/utilities → @import 'tailwindcss' - postcss.config switched from tailwindcss + autoprefixer to @tailwindcss/postcss (autoprefixer baked in) - focus-visible:outline-none → focus-visible:outline-hidden (the v3 utility was a footgun — outline still showed in forced-colors mode) Reverted the migration tool's over-zealous variant="outline" → variant="outline-solid" rename on CVA prop values; that rename was meant for the Tailwind `outline:` utility, not our Button/Badge component variants. Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css (v4-native @import). Same utility surface (animate-spin, animate-in, etc.), one fewer JS plugin in the bundle. Fixed the upgrade tool's malformed dark variant (@custom-variant dark (&:is(class *)) — `class` was being parsed as a tag) to canonical &:where(.dark, .dark *). Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings), vitest 1315/1315, next build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
758 lines
27 KiB
TypeScript
758 lines
27 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: 'open',
|
|
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: 'open',
|
|
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" />
|
|
</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" />
|
|
</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</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" />
|
|
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 "Open" 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" />
|
|
)}
|
|
{isEdit ? 'Save Changes' : 'Create 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
|
|
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`;
|
|
}
|