feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { Loader2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Plus, X, ChevronsUpDown, Check } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -19,13 +20,39 @@ import {
|
||||
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';
|
||||
|
||||
@@ -52,8 +79,29 @@ interface CompanyFormProps {
|
||||
|
||||
export function CompanyForm({ open, onOpenChange, company }: 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,
|
||||
@@ -108,13 +156,80 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
method: 'PATCH',
|
||||
body: rest,
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/companies', { method: 'POST', body: data });
|
||||
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: () => {
|
||||
onSuccess: async (newCompanyId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
||||
onOpenChange(false);
|
||||
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';
|
||||
@@ -122,6 +237,15 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
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">
|
||||
@@ -242,6 +366,68 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
|
||||
<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" /> New client
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setYachtFormOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" /> New yacht
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
@@ -279,6 +465,238 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
</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-[14rem] 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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user