Files
pn-new-crm/src/components/companies/company-form.tsx
Matt 0c6e7b72af feat(forms): migrate remaining native date inputs to <DatePicker> / <DateTimePicker>
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>
2026-05-21 20:14:33 +02:00

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&apos;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>
);
}