Sweeps the last ~17 native `<Input type="date"|"datetime-local">`
call sites onto the shared `<DatePicker>` / `<DateTimePicker>`
primitives so date entry is uniform across the app (calendar popover
on desktop, native OS picker on mobile via the primitive's
viewport-aware fallback).
Three patterns handled:
1. Controlled value/onChange — direct swap to <DatePicker
value/onChange>:
audit-log-list.tsx (audit-from / audit-to filters)
reports/generate-report-form.tsx (date range)
scan/scan-shell.tsx (expense date)
reservations/reservation-detail.tsx (end-reservation dialog)
shared/filter-bar.tsx ('date' filter variant)
2. RHF `register('field')` pattern — wrapped in <Controller> with
field.value/field.onChange bridge. The picker's '' → undefined
normalisation kicks in via `field.onChange(v || undefined)`:
berths/berth-form.tsx (tenureStartDate + tenureEndDate)
reservations/berth-reserve-dialog.tsx (startDate)
companies/add-membership-dialog.tsx (startDate)
yachts/yacht-transfer-dialog.tsx (effectiveDate)
invoices/invoice-detail.tsx (paymentDate)
3. RHF + Date-typed schema — same Controller wrap, plus a
Date<->YYYY-MM-DD bridge in the render() since the zod schema
coerces these to Date:
expenses/expense-form-dialog.tsx (expenseDate)
companies/company-form.tsx (incorporationDate)
4. Datetime variants — swapped onto <DateTimePicker>:
interests/interest-contact-log-tab.tsx (occurredAt + followUpAt)
Skipped because they ARE picker primitives or internal date variants:
- ui/date-picker.tsx, ui/date-time-picker.tsx (the primitives)
- shared/inline-editable-field.tsx (the InlineEditableField date variant)
- dashboard/date-range-picker.tsx (its own popover with min/max gating
that doesn't map cleanly onto the shared primitive)
Removed now-unused Input imports from four files.
Verified: tsc clean, vitest 1448/1448.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
734 lines
27 KiB
TypeScript
734 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useForm, Controller } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Loader2, Plus, X, ChevronsUpDown, Check } from 'lucide-react';
|
|
import { z } from 'zod';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } 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 { Badge } from '@/components/ui/badge';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { TagPicker } from '@/components/shared/tag-picker';
|
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
|
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
|
import { ClientForm } from '@/components/clients/client-form';
|
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
|
import { InterestForm } from '@/components/interests/interest-form';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { useEntityOptions } from '@/hooks/use-entity-options';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
type CompanyStatus = 'active' | 'dissolved';
|
|
|
|
type CompanyFormValues = z.input<typeof createCompanySchema>;
|
|
|
|
interface CompanyFormProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** If provided, form is in edit mode */
|
|
company?: {
|
|
id: string;
|
|
name: string;
|
|
legalName: string | null;
|
|
taxId: string | null;
|
|
registrationNumber: string | null;
|
|
incorporationCountryIso: string | null;
|
|
incorporationSubdivisionIso: string | null;
|
|
incorporationDate: string | null;
|
|
status: string;
|
|
billingEmail: string | null;
|
|
notes: string | null;
|
|
};
|
|
/**
|
|
* Optional initial values for the create flow — used by the global
|
|
* command-search quick-create ("New company 'matthew'" → lands on
|
|
* `/companies?create=1&prefill_name=matthew`). Ignored in edit mode.
|
|
*/
|
|
prefill?: {
|
|
name?: string;
|
|
};
|
|
}
|
|
|
|
export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFormProps) {
|
|
const queryClient = useQueryClient();
|
|
const router = useRouter();
|
|
const isEdit = !!company;
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
// Connection state — only used in create mode. Editing companies is done
|
|
// from the detail page where members + yachts have their own tabs that
|
|
// know how to handle removal / reassignment cleanly.
|
|
const [attachedClientIds, setAttachedClientIds] = useState<string[]>([]);
|
|
const [attachedYachtIds, setAttachedYachtIds] = useState<string[]>([]);
|
|
const [clientFormOpen, setClientFormOpen] = useState(false);
|
|
const [yachtFormOpen, setYachtFormOpen] = useState(false);
|
|
// After successful save the dialog flow can branch: ask the rep whether to
|
|
// also attach the picked clients' yachts (when any of them own yachts), and
|
|
// optionally chain to a New Interest form pre-filled with one of the
|
|
// attached clients.
|
|
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(null);
|
|
const [pendingYachtPullIn, setPendingYachtPullIn] = useState<
|
|
{ yachtId: string; yachtName: string }[] | null
|
|
>(null);
|
|
// Reserved for the inverse pull-in (attached yacht → owner client). Wired
|
|
// through but the inferring query is deferred — owner history isn't yet
|
|
// surfaced cheaply via the yacht endpoint.
|
|
// const [pendingOwnerPullIn, setPendingOwnerPullIn] = useState<...>(null);
|
|
const [createInterestFor, setCreateInterestFor] = useState<string | null>(null);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
watch,
|
|
setValue,
|
|
reset,
|
|
control,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<CompanyFormValues>({
|
|
resolver: zodResolver(createCompanySchema),
|
|
defaultValues: {
|
|
name: '',
|
|
status: 'active',
|
|
tagIds: [],
|
|
},
|
|
});
|
|
|
|
const tagIds = watch('tagIds') ?? [];
|
|
const status = watch('status') ?? 'active';
|
|
|
|
// Populate form when editing, or reset to defaults in create mode.
|
|
useEffect(() => {
|
|
if (company && open) {
|
|
reset({
|
|
name: company.name,
|
|
legalName: company.legalName ?? undefined,
|
|
taxId: company.taxId ?? undefined,
|
|
registrationNumber: company.registrationNumber ?? undefined,
|
|
incorporationCountryIso: company.incorporationCountryIso ?? undefined,
|
|
incorporationSubdivisionIso: company.incorporationSubdivisionIso ?? undefined,
|
|
incorporationDate: company.incorporationDate
|
|
? new Date(company.incorporationDate)
|
|
: undefined,
|
|
status: (company.status as CompanyStatus) ?? 'active',
|
|
billingEmail: company.billingEmail ?? undefined,
|
|
notes: company.notes ?? undefined,
|
|
tagIds: [],
|
|
});
|
|
} else if (!company && open) {
|
|
reset({ name: prefill?.name ?? '', status: 'active', tagIds: [] });
|
|
}
|
|
setFormError(null);
|
|
}, [company, open, reset, prefill]);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (data: CreateCompanyInput) => {
|
|
if (isEdit) {
|
|
// updateCompanySchema omits tagIds - strip them from PATCH body.
|
|
const { tagIds: _tIds, ...rest } = data;
|
|
void _tIds;
|
|
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
|
method: 'PATCH',
|
|
body: rest,
|
|
});
|
|
return null;
|
|
}
|
|
const res = await apiFetch<{ data: { id: string } }>('/api/v1/companies', {
|
|
method: 'POST',
|
|
body: data,
|
|
});
|
|
const newCompanyId = res.data.id;
|
|
// Connect each attached client as a company member. Failures collected
|
|
// here surface as a toast but don't roll back the company create — the
|
|
// rep can fix individual mismatches from the company detail page.
|
|
for (const clientId of attachedClientIds) {
|
|
try {
|
|
await apiFetch(`/api/v1/companies/${newCompanyId}/members`, {
|
|
method: 'POST',
|
|
body: { clientId, role: 'member' },
|
|
});
|
|
} catch (err) {
|
|
toastError(err);
|
|
}
|
|
}
|
|
// Transfer ownership of each attached yacht to the company. This uses
|
|
// the existing yacht-transfer endpoint so the audit log + ownership
|
|
// history records the change just like a manual transfer would.
|
|
for (const yachtId of attachedYachtIds) {
|
|
try {
|
|
await apiFetch(`/api/v1/yachts/${yachtId}/transfer`, {
|
|
method: 'POST',
|
|
body: {
|
|
newOwner: { type: 'company', id: newCompanyId },
|
|
reason: 'Attached during company creation',
|
|
},
|
|
});
|
|
} catch (err) {
|
|
toastError(err);
|
|
}
|
|
}
|
|
return newCompanyId;
|
|
},
|
|
onSuccess: async (newCompanyId) => {
|
|
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
|
if (isEdit || !newCompanyId) {
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
setCreatedCompanyId(newCompanyId);
|
|
|
|
// Step 2a: If any attached client owns yachts the rep didn't already
|
|
// attach, prompt to pull them in. Resolved here so the rep can opt out
|
|
// per-yacht rather than getting a blanket "everything attached" flow.
|
|
try {
|
|
const yachtsToOffer: { yachtId: string; yachtName: string }[] = [];
|
|
for (const clientId of attachedClientIds) {
|
|
const res = await apiFetch<{
|
|
data: Array<{ id: string; name: string; currentOwnerType: string }>;
|
|
}>(`/api/v1/yachts?ownerType=client&ownerId=${clientId}`);
|
|
for (const y of res.data) {
|
|
if (!attachedYachtIds.includes(y.id)) {
|
|
yachtsToOffer.push({ yachtId: y.id, yachtName: y.name });
|
|
}
|
|
}
|
|
}
|
|
if (yachtsToOffer.length > 0) {
|
|
setPendingYachtPullIn(yachtsToOffer);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Yacht lookup failure is non-fatal — fall through to interest prompt.
|
|
}
|
|
|
|
// (Step 2b — yacht-owner pull-in — deferred. Adding it cleanly needs
|
|
// the yachts API to surface prior owners post-transfer, which currently
|
|
// only lives in the activity log. Tracked for follow-up.)
|
|
|
|
finishWithInterestPrompt(newCompanyId);
|
|
},
|
|
onError: (err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : 'Failed to save company';
|
|
setFormError(msg);
|
|
},
|
|
});
|
|
|
|
function finishWithInterestPrompt(newCompanyId: string) {
|
|
void newCompanyId;
|
|
if (attachedClientIds.length > 0) {
|
|
setCreateInterestFor(attachedClientIds[0] ?? null);
|
|
} else {
|
|
onOpenChange(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
|
<SheetHeader>
|
|
<SheetTitle>{isEdit ? 'Edit Company' : 'New Company'}</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<form
|
|
onSubmit={handleSubmit((data) => {
|
|
setFormError(null);
|
|
mutation.mutate(data as CreateCompanyInput);
|
|
})}
|
|
className="space-y-6 py-6"
|
|
>
|
|
{/* Basics */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Basics
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="col-span-2 space-y-1">
|
|
<Label>Name *</Label>
|
|
<Input {...register('name')} placeholder="Acme Holdings Ltd" />
|
|
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
|
</div>
|
|
|
|
<div className="col-span-2 space-y-1">
|
|
<Label>Legal Name</Label>
|
|
<Input {...register('legalName')} placeholder="Acme Holdings Limited" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Registration */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Registration
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1">
|
|
<Label>Tax ID</Label>
|
|
<Input {...register('taxId')} placeholder="VAT / EIN" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Registration Number</Label>
|
|
<Input {...register('registrationNumber')} placeholder="Company #" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Incorporation Country</Label>
|
|
<CountryCombobox
|
|
value={watch('incorporationCountryIso')}
|
|
onChange={(iso) => {
|
|
setValue('incorporationCountryIso', iso ?? undefined);
|
|
// Wipe subdivision when country flips - codes are country-scoped.
|
|
setValue('incorporationSubdivisionIso', undefined);
|
|
}}
|
|
data-testid="company-incorp-country"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Incorporation Region</Label>
|
|
<SubdivisionCombobox
|
|
value={watch('incorporationSubdivisionIso')}
|
|
onChange={(code) => setValue('incorporationSubdivisionIso', code ?? undefined)}
|
|
country={(watch('incorporationCountryIso') as CountryCode | undefined) ?? null}
|
|
data-testid="company-incorp-subdivision"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Incorporation Date</Label>
|
|
<Controller
|
|
control={control}
|
|
name="incorporationDate"
|
|
render={({ field }) => {
|
|
// Schema coerces incorporationDate to a Date; the picker
|
|
// speaks YYYY-MM-DD. Bridge both directions so validation
|
|
// + downstream API payload stay unchanged.
|
|
const isoValue =
|
|
field.value instanceof Date
|
|
? field.value.toISOString().split('T')[0]
|
|
: typeof field.value === 'string'
|
|
? (field.value as string).split('T')[0]
|
|
: '';
|
|
return (
|
|
<DatePicker
|
|
value={isoValue}
|
|
onChange={(v) => field.onChange(v ? new Date(v) : undefined)}
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
{errors.incorporationDate && (
|
|
<p className="text-xs text-destructive">{errors.incorporationDate.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Contact & Status */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Contact & Status
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1">
|
|
<Label>Billing Email</Label>
|
|
<Input
|
|
type="email"
|
|
{...register('billingEmail')}
|
|
placeholder="billing@example.com"
|
|
/>
|
|
{errors.billingEmail && (
|
|
<p className="text-xs text-destructive">{errors.billingEmail.message}</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Status</Label>
|
|
<Select
|
|
value={status}
|
|
onValueChange={(v) => setValue('status', v as CompanyStatus)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="dissolved">Dissolved</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Connections — only on create. Editing membership / yacht ownership
|
|
from this form would race with the same actions on the detail
|
|
tabs (and the audit trail of a "create + attach 5 clients in one
|
|
flow" is much more readable than 6 separate create rows). */}
|
|
{!isEdit && (
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Connections
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Member clients</Label>
|
|
<EntityMultiPicker
|
|
endpoint="/api/v1/clients/options"
|
|
labelKey="fullName"
|
|
placeholder="Add a client…"
|
|
selectedIds={attachedClientIds}
|
|
onChange={setAttachedClientIds}
|
|
/>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Each pick becomes a company member with role=member. You can refine roles
|
|
afterwards on the Members tab.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Yachts owned by the company</Label>
|
|
<EntityMultiPicker
|
|
endpoint="/api/v1/yachts/options"
|
|
labelKey="name"
|
|
placeholder="Add a yacht…"
|
|
selectedIds={attachedYachtIds}
|
|
onChange={setAttachedYachtIds}
|
|
/>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Adding a yacht transfers its ownership to this company (logged in the
|
|
yacht's audit trail). Skip if you only want to associate without changing
|
|
ownership.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setClientFormOpen(true)}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden /> New client
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setYachtFormOpen(true)}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden /> New yacht
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* Notes */}
|
|
<div className="space-y-2">
|
|
<Label>Notes</Label>
|
|
<Textarea
|
|
{...register('notes')}
|
|
placeholder="Internal notes about this company"
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Tags */}
|
|
<div className="space-y-2">
|
|
<Label>Tags</Label>
|
|
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
|
</div>
|
|
|
|
{formError && (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{formError}
|
|
</p>
|
|
)}
|
|
|
|
<SheetFooter>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
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 Company'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</form>
|
|
</SheetContent>
|
|
|
|
{/* Stacked "+ New client" / "+ New yacht" forms. On successful create
|
|
the picker we open them from doesn't know the new id yet — the
|
|
ClientList / YachtList query refetches via react-query invalidation
|
|
and the rep can pick the new entity from the dropdown immediately. */}
|
|
<ClientForm open={clientFormOpen} onOpenChange={setClientFormOpen} />
|
|
{yachtFormOpen && (
|
|
<YachtForm
|
|
open={yachtFormOpen}
|
|
onOpenChange={setYachtFormOpen}
|
|
// No initialOwner — the new yacht starts unowned-by-rules-engine; the
|
|
// company-form will optionally transfer it on save.
|
|
/>
|
|
)}
|
|
|
|
<AlertDialog
|
|
open={!!pendingYachtPullIn}
|
|
onOpenChange={(o) => {
|
|
if (!o && createdCompanyId) {
|
|
setPendingYachtPullIn(null);
|
|
finishWithInterestPrompt(createdCompanyId);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Attach these yachts too?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
The clients you added own {pendingYachtPullIn?.length ?? 0}{' '}
|
|
{pendingYachtPullIn?.length === 1 ? 'yacht' : 'yachts'} not yet linked to this
|
|
company. Attaching transfers their ownership.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="space-y-1 rounded-md border bg-muted/30 p-2 text-sm">
|
|
{pendingYachtPullIn?.map((y) => (
|
|
<div key={y.yachtId} className="flex items-center justify-between">
|
|
<span className="truncate">{y.yachtName}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel
|
|
onClick={() => {
|
|
setPendingYachtPullIn(null);
|
|
if (createdCompanyId) finishWithInterestPrompt(createdCompanyId);
|
|
}}
|
|
>
|
|
Skip
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={async () => {
|
|
if (!createdCompanyId || !pendingYachtPullIn) return;
|
|
for (const y of pendingYachtPullIn) {
|
|
try {
|
|
await apiFetch(`/api/v1/yachts/${y.yachtId}/transfer`, {
|
|
method: 'POST',
|
|
body: {
|
|
newOwner: { type: 'company', id: createdCompanyId },
|
|
reason: 'Attached during company creation (yacht pull-in)',
|
|
},
|
|
});
|
|
} catch (err) {
|
|
toastError(err);
|
|
}
|
|
}
|
|
setPendingYachtPullIn(null);
|
|
finishWithInterestPrompt(createdCompanyId);
|
|
}}
|
|
>
|
|
Attach all
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog
|
|
open={!!createInterestFor && !pendingYachtPullIn}
|
|
onOpenChange={(o) => {
|
|
if (!o) {
|
|
setCreateInterestFor(null);
|
|
onOpenChange(false);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Create an interest now?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
The new company is connected to {attachedClientIds.length}{' '}
|
|
{attachedClientIds.length === 1 ? 'client' : 'clients'}. Want to open a new interest
|
|
dialog pre-filled with one of them?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel
|
|
onClick={() => {
|
|
setCreateInterestFor(null);
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
Not now
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => {
|
|
// Close the company form, then open the interest form. The
|
|
// interest form is rendered below via createInterestFor.
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
Create interest
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Detached follow-up: interest form pre-filled with the first attached
|
|
client. Stays mounted after this form closes so the rep can finish
|
|
the new-interest flow uninterrupted. */}
|
|
{createInterestFor && !open && (
|
|
<InterestForm
|
|
open={true}
|
|
onOpenChange={(o) => {
|
|
if (!o) {
|
|
setCreateInterestFor(null);
|
|
router.refresh();
|
|
}
|
|
}}
|
|
defaultClientId={createInterestFor}
|
|
/>
|
|
)}
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lightweight multi-pick combobox. Used by the company-form Connections
|
|
* section for both clients and yachts since they share the same shape
|
|
* (`{ value, label }` via useEntityOptions). Selected items render as
|
|
* removable chips above the picker so the rep can see at a glance what
|
|
* they're about to attach.
|
|
*/
|
|
function EntityMultiPicker({
|
|
endpoint,
|
|
labelKey,
|
|
placeholder,
|
|
selectedIds,
|
|
onChange,
|
|
}: {
|
|
endpoint: string;
|
|
labelKey: string;
|
|
placeholder: string;
|
|
selectedIds: string[];
|
|
onChange: (ids: string[]) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const { options, setSearch } = useEntityOptions({ endpoint, labelKey });
|
|
const labelById = useMemo(() => {
|
|
const m = new Map<string, string>();
|
|
for (const o of options) m.set(o.value, o.label);
|
|
return m;
|
|
}, [options]);
|
|
|
|
function toggle(id: string) {
|
|
if (selectedIds.includes(id)) {
|
|
onChange(selectedIds.filter((x) => x !== id));
|
|
} else {
|
|
onChange([...selectedIds, id]);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{selectedIds.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{selectedIds.map((id) => (
|
|
<Badge key={id} variant="secondary" className="gap-1 pr-1">
|
|
<span className="max-w-56 truncate">{labelById.get(id) ?? id.slice(0, 8)}</span>
|
|
<button
|
|
type="button"
|
|
className="rounded-full p-0.5 hover:bg-muted-foreground/20"
|
|
onClick={() => toggle(id)}
|
|
aria-label="Remove"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<Popover open={open} onOpenChange={setOpen} modal>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className={cn(
|
|
'w-full justify-between font-normal',
|
|
!selectedIds.length && 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{placeholder}
|
|
<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" align="start">
|
|
<Command shouldFilter={false}>
|
|
<CommandInput placeholder="Search…" onValueChange={setSearch} />
|
|
<CommandList>
|
|
<CommandEmpty>No results.</CommandEmpty>
|
|
<CommandGroup>
|
|
{options.map((opt) => {
|
|
const isSelected = selectedIds.includes(opt.value);
|
|
return (
|
|
<CommandItem
|
|
key={opt.value}
|
|
value={opt.value}
|
|
onSelect={() => toggle(opt.value)}
|
|
>
|
|
<Check
|
|
className={cn('mr-2 h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')}
|
|
/>
|
|
{opt.label}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|