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:
@@ -230,8 +230,12 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="documenso-template">Generated EOI — rendered + signed externally</SelectItem>
|
||||
<SelectItem value="inapp">Manual EOI — rendered in CRM, sent for e-signature</SelectItem>
|
||||
<SelectItem value="documenso-template">
|
||||
Generated EOI — rendered + signed externally
|
||||
</SelectItem>
|
||||
<SelectItem value="inapp">
|
||||
Manual EOI — rendered in CRM, sent for e-signature
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,8 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.')) return;
|
||||
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.'))
|
||||
return;
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });
|
||||
|
||||
@@ -26,6 +26,9 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
||||
|
||||
@@ -39,6 +42,7 @@ interface InAppTemplate {
|
||||
interface EoiContextResponse {
|
||||
data: {
|
||||
client: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality: string | null;
|
||||
primaryEmail: string | null;
|
||||
@@ -46,6 +50,7 @@ interface EoiContextResponse {
|
||||
address: { street: string; city: string; country: string } | null;
|
||||
};
|
||||
yacht: {
|
||||
id: string;
|
||||
name: string;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
@@ -119,6 +124,17 @@ export function EoiGenerateDialog({
|
||||
|
||||
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
||||
|
||||
async function patchClient(body: Record<string, unknown>) {
|
||||
if (!ctx) return;
|
||||
await apiFetch(`/api/v1/clients/${ctx.client.id}`, { method: 'PATCH', body });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
|
||||
}
|
||||
async function patchYacht(body: Record<string, unknown>) {
|
||||
if (!ctx?.yacht) return;
|
||||
await apiFetch(`/api/v1/yachts/${ctx.yacht.id}`, { method: 'PATCH', body });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
|
||||
}
|
||||
|
||||
// Required for the EOI's top paragraph (Section 2). Without these
|
||||
// the document is unsignable, so generation is blocked.
|
||||
const required = ctx
|
||||
@@ -128,6 +144,27 @@ export function EoiGenerateDialog({
|
||||
label: 'Full name',
|
||||
value: ctx.client.fullName,
|
||||
present: !!ctx.client.fullName,
|
||||
edit: {
|
||||
onSave: async (next: string | null) =>
|
||||
await patchClient({ fullName: next ?? '' }),
|
||||
placeholder: 'Full legal name',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'nationality',
|
||||
label: 'Nationality',
|
||||
value: ctx.client.nationality,
|
||||
present: !!ctx.client.nationality,
|
||||
edit: {
|
||||
variant: 'country' as const,
|
||||
onSave: async (next: string | null) => {
|
||||
// Country combobox emits the ISO code; the read-only string is the
|
||||
// localised country name (resolved server-side). Coerce here so we
|
||||
// store the canonical ISO.
|
||||
const iso = next ? (next as string).toUpperCase() : null;
|
||||
await patchClient({ nationalityIso: iso });
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
@@ -155,6 +192,13 @@ export function EoiGenerateDialog({
|
||||
key: 'yacht',
|
||||
label: 'Yacht name',
|
||||
value: ctx.yacht?.name ?? null,
|
||||
edit: ctx.yacht
|
||||
? {
|
||||
onSave: async (next: string | null) =>
|
||||
await patchYacht({ name: next ?? '' }),
|
||||
placeholder: 'Yacht name',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: 'dimensions',
|
||||
@@ -263,8 +307,12 @@ export function EoiGenerateDialog({
|
||||
<PreviewRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
// Nationality stores the localised country name in the preview
|
||||
// but commits the ISO. Pass the underlying ISO via a closure
|
||||
// so the CountryCombobox can highlight it correctly.
|
||||
value={row.value}
|
||||
missing={!row.present}
|
||||
edit={row.edit}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
@@ -275,22 +323,44 @@ export function EoiGenerateDialog({
|
||||
</p>
|
||||
<dl className="space-y-1.5">
|
||||
{optional.map((row) => (
|
||||
<PreviewRow key={row.key} label={row.label} value={row.value} />
|
||||
<PreviewRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
edit={row.edit}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
{portSlug && clientId && (
|
||||
<div className="border-t pt-2">
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${clientId}` as any}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Wrong details? Edit on the client's page
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Editing name / nationality / yacht name above patches the underlying records
|
||||
directly. For phone, address, or to manage linked berths, jump to the canonical
|
||||
page:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${clientId}` as any}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Edit client details
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/interests/${interestId}` as any}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Manage linked berths
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -328,17 +398,48 @@ function PreviewRow({
|
||||
label,
|
||||
value,
|
||||
missing = false,
|
||||
edit,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
missing?: boolean;
|
||||
/** When provided, renders a pencil affordance that opens an inline editor.
|
||||
* The save handler is owned by the row config so each field can hit the
|
||||
* right API (clients PATCH, yachts PATCH, …). */
|
||||
edit?: {
|
||||
onSave: (next: string | null) => Promise<void>;
|
||||
variant?: 'text' | 'country';
|
||||
placeholder?: string;
|
||||
};
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function commit(next: string) {
|
||||
const trimmed = next.trim();
|
||||
if (!edit) return;
|
||||
if (trimmed === (value ?? '')) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await edit.onSave(trimmed === '' ? null : trimmed);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'flex-1 break-words',
|
||||
'flex-1 break-words inline-flex items-center gap-2',
|
||||
missing
|
||||
? 'text-rose-700 font-medium'
|
||||
: value
|
||||
@@ -346,7 +447,54 @@ function PreviewRow({
|
||||
: 'text-muted-foreground italic',
|
||||
)}
|
||||
>
|
||||
{value ?? (missing ? 'Missing — required' : 'Not set')}
|
||||
{edit && editing ? (
|
||||
edit.variant === 'country' ? (
|
||||
<CountryCombobox
|
||||
value={value}
|
||||
onChange={(iso) => void commit(iso ?? '')}
|
||||
defaultOpen
|
||||
onOpenChange={(o) => !o && setEditing(false)}
|
||||
compact={false}
|
||||
className="h-7 w-full"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void commit(draft);
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => !saving && void commit(draft)}
|
||||
placeholder={edit.placeholder}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
className="h-7 text-sm"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{value ?? (missing ? 'Missing — required' : 'Not set')}
|
||||
</span>
|
||||
{edit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(value ?? '');
|
||||
setEditing(true);
|
||||
}}
|
||||
className="ml-1 inline-flex items-center rounded p-0.5 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
aria-label={`Edit ${label}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,11 +69,7 @@ export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: Folder
|
||||
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Folders
|
||||
</div>
|
||||
<TreeBody
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={onSelect}
|
||||
footer={footer}
|
||||
/>
|
||||
<TreeBody selectedFolderId={selectedFolderId} onSelect={onSelect} footer={footer} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user