Wave A (Interest+EOI form quick wins): - Auto-select yacht after inline-create from interest form - EOI generate dialog: "View EOI" action toast - Interest form berth picker: formatBerthRange compact label - Remove "Generate EOI" button from Documents tab (clean removal) - Interest auto-assign: only sales_agent/sales_manager auto-claim ownership on create (explicit role check via user_port_roles join) - LinkedBerthRowItem dims: drop "D" suffix + "L × W" format - ExternalEoiUploadDialog: prefillSignatories prop threaded from active EOI signers - EOI signature progress on Overview milestone card footer Wave B (a11y + i18n sweeps): - aria-live on supplemental-info error state - text-[10px] -> text-xs in client-pipeline-summary - Currency formatter: locale default removed (Intl uses runtime) - en-US/en-GB hardcoded toLocaleString swept across 13 components Wave C (Primary berth always in EOI bundle): - Service guard strengthened on update path - Migration 0083 backfills historical primary rows Wave D (Onboarding super_admin discoverability): - /api/v1/admin/onboarding/status endpoint + shared service - Topbar OnboardingBanner (super_admin, session-dismissible) - OnboardingTile dashboard widget (rail group, self-hides at 100%) - Celebration toast + invalidate of shared status on last tick Wave E (Branded post-completion email idempotency): - Verified handleDocumentCompleted already owns the email fan-out - Added regression test for the polling path + idempotency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
948 lines
38 KiB
TypeScript
948 lines
38 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 { toast } from 'sonner';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
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 { formatBerthRange } from '@/lib/templates/berth-range';
|
|
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 router = useRouter();
|
|
const params = useParams<{ portSlug: string }>();
|
|
const portSlug = params?.portSlug ?? '';
|
|
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',
|
|
// Default a manually-created interest's source to 'manual' so the
|
|
// rep doesn't have to remember to pick it (mirrors the same
|
|
// default on client-form.tsx). Inquiry-inbox / website conversion
|
|
// flows can override via prefill once that path lands here.
|
|
source: 'manual',
|
|
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);
|
|
|
|
// Auto-fill pipelineStage + leadCategory based on whether a berth was
|
|
// picked. Once the rep manually edits either field we stop touching it,
|
|
// so we don't fight the user. Edit mode skips the auto-fill entirely -
|
|
// changing the berth on an in-flight interest shouldn't silently demote
|
|
// it back to "enquiry".
|
|
const userTouchedStage = useRef(false);
|
|
const userTouchedCategory = useRef(false);
|
|
useEffect(() => {
|
|
if (isEdit) return;
|
|
const hasBerth = !!selectedBerthId;
|
|
if (!userTouchedStage.current) {
|
|
setValue('pipelineStage', hasBerth ? 'qualified' : 'enquiry');
|
|
}
|
|
if (!userTouchedCategory.current) {
|
|
setValue('leadCategory', hasBerth ? 'specific_qualified' : 'general_interest');
|
|
}
|
|
// setValue is stable from RHF; isEdit doesn't change after mount.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedBerthId]);
|
|
|
|
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;
|
|
|
|
// Probe whether the selected client (or their member companies) owns any
|
|
// yachts. When zero, the form swaps the picker for an "Add yacht" CTA so
|
|
// reps don't get stuck on an empty dropdown wondering what to do. We hit
|
|
// the same autocomplete endpoint the picker uses but with an empty query
|
|
// to get the full unfiltered list scoped to the owner filter.
|
|
// Tags-availability probe - drives whether the whole Tags section
|
|
// (label + picker) renders. The picker itself returns null when empty,
|
|
// but the wrapping label/separator needed the same gate.
|
|
const { data: tagsList } = useQuery<{ data: Array<{ id: string }> }>({
|
|
queryKey: ['tag-availability-for-interest-form'],
|
|
queryFn: () => apiFetch('/api/v1/tags/options'),
|
|
staleTime: 60_000,
|
|
});
|
|
const tagsAvailable = (tagsList?.data?.length ?? 0) > 0;
|
|
|
|
const { data: yachtCount } = useQuery<{ data: Array<{ id: string }> }>({
|
|
queryKey: [
|
|
'yacht-count-for-interest-form',
|
|
selectedClientId,
|
|
memberCompanyIds.sort().join(','),
|
|
],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams({ q: '' });
|
|
if (selectedClientId) params.set('ownerClientId', selectedClientId);
|
|
if (memberCompanyIds.length > 0) {
|
|
params.set('ownerCompanyIds', memberCompanyIds.join(','));
|
|
}
|
|
return apiFetch(`/api/v1/yachts/autocomplete?${params.toString()}`);
|
|
},
|
|
enabled: !!selectedClientId,
|
|
});
|
|
const hasAnyYachts = (yachtCount?.data?.length ?? 0) > 0;
|
|
|
|
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 },
|
|
});
|
|
}
|
|
return { id: interest!.id, created: false };
|
|
}
|
|
const res = await apiFetch<{ data: { id: string } }>('/api/v1/interests', {
|
|
method: 'POST',
|
|
body: enriched,
|
|
});
|
|
// Materialise any additional berths the rep picked in the multi-
|
|
// select. The first (primary) berth is already linked via the create
|
|
// payload's berthId; everything else gets a follow-up POST to the
|
|
// junction endpoint. We fire them in parallel - failure on one is
|
|
// surfaced as a toast but doesn't roll back the interest creation.
|
|
if (additionalBerthIds.length > 0) {
|
|
await Promise.allSettled(
|
|
additionalBerthIds.map((berthId) =>
|
|
apiFetch(`/api/v1/interests/${res.data.id}/berths`, {
|
|
method: 'POST',
|
|
body: { berthId, isSpecificInterest: false },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
return { id: res.data.id, created: true };
|
|
},
|
|
onSuccess: (result) => {
|
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
|
// M-U10: confirm the write landed.
|
|
toast.success(result.created ? 'Interest created' : 'Interest updated');
|
|
onOpenChange(false);
|
|
// F20: navigate to the new interest's detail page so the rep can
|
|
// start the workflow immediately. Edits stay in place - no point
|
|
// re-loading the same row's detail page they just came from.
|
|
if (result.created && portSlug) {
|
|
router.push(`/${portSlug}/interests/${result.id}` as never);
|
|
}
|
|
},
|
|
});
|
|
|
|
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);
|
|
|
|
// Additional berths (beyond the primary `berthId`) accumulated by the
|
|
// multi-select. On create, after the interest row exists, each id here
|
|
// gets a follow-up POST /interests/{id}/berths so they show up in the
|
|
// linked-berths list with isPrimary=false. The primary berth (the form's
|
|
// `berthId`) is materialised by the standard create path. Edit mode
|
|
// doesn't surface this - managing extra berths post-create happens on
|
|
// the interest detail page's linked-berths section.
|
|
const [additionalBerthIds, setAdditionalBerthIds] = useState<string[]>([]);
|
|
|
|
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>Berths (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 &&
|
|
additionalBerthIds.length === 0 &&
|
|
'text-muted-foreground',
|
|
)}
|
|
>
|
|
<span className="truncate">
|
|
{selectedBerthId
|
|
? (() => {
|
|
const primaryLabel =
|
|
selectedBerth?.label ??
|
|
interest?.berthMooringNumber ??
|
|
selectedBerthId;
|
|
const additionalLabels = additionalBerthIds
|
|
.map((id) => berthOptions.find((b) => b.value === id)?.label)
|
|
.filter((label): label is string => Boolean(label));
|
|
const allLabels = [primaryLabel, ...additionalLabels];
|
|
const range = formatBerthRange(allLabels);
|
|
// Cap at 5 segments after range collapse so "A1-A3, B5, C2, D7, E4 +N more"
|
|
// doesn't overflow the trigger.
|
|
const segments = range ? range.split(', ') : [];
|
|
if (segments.length <= 5) return range || primaryLabel;
|
|
const head = segments.slice(0, 5).join(', ');
|
|
const overflow = segments.length - 5;
|
|
return `${head} +${overflow} more`;
|
|
})()
|
|
: 'Select berths…'}
|
|
</span>
|
|
<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="__clear__"
|
|
onSelect={() => {
|
|
setValue('berthId', undefined);
|
|
setAdditionalBerthIds([]);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
'mr-2 h-4 w-4',
|
|
!selectedBerthId && additionalBerthIds.length === 0
|
|
? 'opacity-100'
|
|
: 'opacity-0',
|
|
)}
|
|
/>
|
|
None
|
|
</CommandItem>
|
|
{berthOptions.map((option) => {
|
|
const isPrimary = selectedBerthId === option.value;
|
|
const isAdditional = additionalBerthIds.includes(option.value);
|
|
const isSelected = isPrimary || isAdditional;
|
|
return (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value}
|
|
onSelect={(val) => {
|
|
// Multi-select toggle. First pick becomes
|
|
// the primary berthId (the one the API uses
|
|
// for templates / list views). Subsequent
|
|
// picks go into additionalBerthIds and are
|
|
// materialised via POST /berths after the
|
|
// interest is created.
|
|
if (isPrimary) {
|
|
// Demote primary; promote first additional
|
|
// (if any) to primary so the deal still
|
|
// has one primary berth.
|
|
const promote = additionalBerthIds[0];
|
|
setValue('berthId', promote ?? undefined);
|
|
setAdditionalBerthIds(additionalBerthIds.slice(1));
|
|
} else if (isAdditional) {
|
|
setAdditionalBerthIds(
|
|
additionalBerthIds.filter((id) => id !== val),
|
|
);
|
|
} else if (!selectedBerthId) {
|
|
setValue('berthId', val);
|
|
} else {
|
|
setAdditionalBerthIds([...additionalBerthIds, val]);
|
|
}
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
'mr-2 h-4 w-4',
|
|
isSelected ? 'opacity-100' : 'opacity-0',
|
|
)}
|
|
/>
|
|
<span className="flex-1">{option.label}</span>
|
|
{isPrimary && (
|
|
<span className="ml-2 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
|
|
primary
|
|
</span>
|
|
)}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<p className="text-xs text-muted-foreground">
|
|
Pick one or more berths. The first becomes the primary berth (used in templates and
|
|
list views); the rest get linked as alternates and can be promoted later from the
|
|
interest detail page.
|
|
</p>
|
|
</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 && hasAnyYachts && (
|
|
<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>
|
|
{/* Hide the picker entirely when the selected client has no
|
|
yachts on file (and isn't linked to a company with yachts).
|
|
An empty dropdown is a dead-end UX - the only useful action
|
|
in that state is "create a yacht for this client". */}
|
|
{selectedClientId && !hasAnyYachts ? (
|
|
<div className="rounded-md border border-dashed bg-muted/40 p-3 text-sm">
|
|
<p className="text-muted-foreground">This client has no yachts on file yet.</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="mt-2"
|
|
onClick={() => setCreateYachtOpen(true)}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden />
|
|
Add a yacht for this client
|
|
</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 New Enquiry 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') ?? 'enquiry'}
|
|
onValueChange={(v) => {
|
|
userTouchedStage.current = true;
|
|
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) => {
|
|
userTouchedCategory.current = true;
|
|
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>
|
|
|
|
{/* Tags - TagPicker itself returns null when the port has no tags
|
|
configured AND the form has nothing selected. We hide the
|
|
wrapping label + separator in that same case so an empty
|
|
"Tags" header doesn't sit in the form. */}
|
|
{(tagIds.length > 0 || tagsAvailable) && (
|
|
<>
|
|
<Separator />
|
|
<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'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 }}
|
|
onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}
|
|
/>
|
|
)}
|
|
</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`;
|
|
}
|