'use client'; import { useEffect, useMemo, useState } from 'react'; import { useForm } 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 { 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; 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(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([]); const [attachedYachtIds, setAttachedYachtIds] = useState([]); 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(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(null); const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting }, } = useForm({ 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 ( {isEdit ? 'Edit Company' : 'New Company'}
{ setFormError(null); mutation.mutate(data as CreateCompanyInput); })} className="space-y-6 py-6" > {/* Basics */}

Basics

{errors.name &&

{errors.name.message}

}
{/* Registration */}

Registration

{ setValue('incorporationCountryIso', iso ?? undefined); // Wipe subdivision when country flips - codes are country-scoped. setValue('incorporationSubdivisionIso', undefined); }} data-testid="company-incorp-country" />
setValue('incorporationSubdivisionIso', code ?? undefined)} country={(watch('incorporationCountryIso') as CountryCode | undefined) ?? null} data-testid="company-incorp-subdivision" />
{errors.incorporationDate && (

{errors.incorporationDate.message}

)}
{/* Contact & Status */}

Contact & Status

{errors.billingEmail && (

{errors.billingEmail.message}

)}
{/* 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 && (

Connections

Each pick becomes a company member with role=member. You can refine roles afterwards on the Members tab.

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.

)} {/* Notes */}