feat(uat-batch): Group B Interest detail polish (5 new ships + 2 verified)
B13–B19 from the 2026-05-21 plan. Five new ships; two items already in
place from earlier work but flagged for verification.
Shipped now:
B14 Interest Overview Email + Phone rows: new <ClientChannelEditor>
combobox. Primary value renders inline (free-text for email,
<InlinePhoneField> for phone with country picker). Chevron opens
a popover listing every contact in the channel — promote to
primary, delete non-primaries, or inline-add a new contact.
Backed by the existing /clients/[id]/contacts CRUD + promote-
to-primary endpoints. Wired into the Email + Phone rows on
interest-tabs.tsx Overview.
B15 Inline phone editor: the phone branch of <ClientChannelEditor>
uses <InlinePhoneField> (country code + national-format split).
interests.service.ts now returns `clientPrimaryPhoneCountry` so
the editor can preserve the ISO-3166-1 alpha-2 round-trip.
B16 Client Overview interest summary: PanelVariant of
<ClientPipelineSummary> renders a one-line "Wants L × W × D ·
Source" under each interest's header when constraints / source
are captured. Hidden when both are empty.
<ClientInterestRow> type extended with the new fields; the
/api/v1/interests query already returns them.
B17 Notes Latest-note teaser stage pill: stage-badge chip next to
the "5 minutes ago · Matt" line. Shows the deal's CURRENT
pipelineStage — a stage-at-note-time lookup would require a
per-render audit_logs read, over-engineered for a context hint.
B18 InterestBerthStatusBanner names + links the competing deal:
reuses /berths/[id]/active-interests endpoint shipped in 292a8b5;
one query per conflicting berth via useQueries. Picks the
isPrimary competing interest (falls back to first non-self
row); renders an inline <Link> to the competing detail page.
Already shipped (verified pre-shipped):
B13 Inbox Reminders embedded filter row — `embedded` prop already
wired in reminder-list.tsx.
B19 Qualification auto-confirm intent at stage ≥ EOI — already
handled by computeAutoSatisfied's `stageIdx > qualifiedIdx`
gate (covers eoi / reservation / deposit_paid / contract).
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
390
src/components/clients/client-channel-editor.tsx
Normal file
390
src/components/clients/client-channel-editor.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ChevronDown, Loader2, Plus, Star, Trash2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
|
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { parsePhone } from '@/lib/i18n/phone';
|
||||||
|
|
||||||
|
type Channel = 'email' | 'phone';
|
||||||
|
|
||||||
|
export interface ContactRow {
|
||||||
|
id: string;
|
||||||
|
channel: 'email' | 'phone' | 'whatsapp' | 'other';
|
||||||
|
value: string;
|
||||||
|
valueE164: string | null;
|
||||||
|
label: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clientId: string;
|
||||||
|
/**
|
||||||
|
* Channel filter — picker shows only `email` (or `phone` + `whatsapp` for
|
||||||
|
* phone-style channels). Edits / promotions stay scoped to the chosen
|
||||||
|
* channel.
|
||||||
|
*/
|
||||||
|
channel: Channel;
|
||||||
|
/** Server-resolved primary contact for this channel (drives the inline
|
||||||
|
* value rendering when the picker isn't open). */
|
||||||
|
primaryContactId: string | null;
|
||||||
|
primaryValue: string | null;
|
||||||
|
/** Phone channel only — E.164 form + ISO-3166-1 alpha-2 country code so the
|
||||||
|
* inline phone editor can preserve the national-format roundtrip. */
|
||||||
|
primaryValueE164?: string | null;
|
||||||
|
primaryValueCountry?: string | null;
|
||||||
|
/** Query keys to invalidate after any mutation succeeds — the parent
|
||||||
|
* detail view is usually keyed on `['interest', interestId]` or
|
||||||
|
* `['clients', clientId]` so the picker can't hard-code which to bump. */
|
||||||
|
invalidateKeys?: ReadonlyArray<readonly unknown[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combobox-style editor for the Email + Phone rows on the Interest /
|
||||||
|
* Client Overview. Renders the current primary value with an inline
|
||||||
|
* edit-on-click; the chevron opens a popover listing every contact in
|
||||||
|
* the channel so the rep can:
|
||||||
|
* - promote a different contact to primary
|
||||||
|
* - edit any contact's value inline
|
||||||
|
* - add a new contact (defaults to non-primary; can be flagged primary
|
||||||
|
* at create time)
|
||||||
|
* - delete a non-primary contact
|
||||||
|
*
|
||||||
|
* Backed by:
|
||||||
|
* GET /api/v1/clients/[id]/contacts
|
||||||
|
* POST /api/v1/clients/[id]/contacts
|
||||||
|
* PATCH /api/v1/clients/[id]/contacts/[contactId]
|
||||||
|
* DELETE /api/v1/clients/[id]/contacts/[contactId]
|
||||||
|
* POST /api/v1/clients/[id]/contacts/[contactId]/promote-to-primary
|
||||||
|
*
|
||||||
|
* Phone channel shows `whatsapp` rows alongside `phone` so the picker
|
||||||
|
* works as the rep's "all the ways I can call this client" surface.
|
||||||
|
*/
|
||||||
|
export function ClientChannelEditor({
|
||||||
|
clientId,
|
||||||
|
channel,
|
||||||
|
primaryContactId,
|
||||||
|
primaryValue,
|
||||||
|
primaryValueE164,
|
||||||
|
primaryValueCountry,
|
||||||
|
invalidateKeys = [],
|
||||||
|
}: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const acceptedChannels: ContactRow['channel'][] =
|
||||||
|
channel === 'email' ? ['email'] : ['phone', 'whatsapp'];
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: ContactRow[] }>({
|
||||||
|
queryKey: ['client-contacts', clientId, channel],
|
||||||
|
queryFn: () => apiFetch<{ data: ContactRow[] }>(`/api/v1/clients/${clientId}/contacts`),
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contacts = (data?.data ?? []).filter((c) => acceptedChannels.includes(c.channel));
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['client-contacts', clientId, channel] });
|
||||||
|
for (const key of invalidateKeys) {
|
||||||
|
void qc.invalidateQueries({ queryKey: key as unknown[] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhone = (v: string) => parsePhone(v).international ?? v;
|
||||||
|
const displayValue = (row: ContactRow) =>
|
||||||
|
row.channel === 'phone' || row.channel === 'whatsapp' ? formatPhone(row.value) : row.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{primaryContactId ? (
|
||||||
|
channel === 'phone' ? (
|
||||||
|
// Phone-specific editor: country picker + national-format input so
|
||||||
|
// the rep doesn't have to type the +44 / +1 prefix manually. The
|
||||||
|
// service auto-derives `valueE164` + `valueCountry` from the
|
||||||
|
// submitted shape, then the `value` (display) form is updated.
|
||||||
|
<InlinePhoneField
|
||||||
|
e164={primaryValueE164 ?? primaryValue ?? null}
|
||||||
|
country={primaryValueCountry ?? null}
|
||||||
|
onSave={async (next) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
value: next.e164 ?? '',
|
||||||
|
valueE164: next.e164 ?? null,
|
||||||
|
valueCountry: next.country,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
invalidate();
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InlineEditableField
|
||||||
|
variant="text"
|
||||||
|
value={primaryValue ?? ''}
|
||||||
|
onSave={async (next) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { value: next },
|
||||||
|
});
|
||||||
|
invalidate();
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`More ${channel} contacts`}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 p-2">
|
||||||
|
<div className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{channel === 'email' ? 'Email contacts' : 'Phone & WhatsApp contacts'}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||||
|
No {channel} contacts yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<ContactRowItem
|
||||||
|
key={c.id}
|
||||||
|
clientId={clientId}
|
||||||
|
row={c}
|
||||||
|
invalidate={invalidate}
|
||||||
|
displayValue={displayValue(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<AddContactRow
|
||||||
|
clientId={clientId}
|
||||||
|
channel={channel}
|
||||||
|
existingCount={contacts.length}
|
||||||
|
invalidate={invalidate}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactRowItem({
|
||||||
|
clientId,
|
||||||
|
row,
|
||||||
|
displayValue,
|
||||||
|
invalidate,
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
row: ContactRow;
|
||||||
|
displayValue: string;
|
||||||
|
invalidate: () => void;
|
||||||
|
}) {
|
||||||
|
const promote = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}/promote-to-primary`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Primary updated');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Contact removed');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="px-1 py-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{row.isPrimary ? (
|
||||||
|
<Star
|
||||||
|
className="h-3 w-3 shrink-0 text-amber-500"
|
||||||
|
aria-label="Primary"
|
||||||
|
aria-hidden={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="h-3 w-3 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm">{displayValue}</span>
|
||||||
|
{row.label ? (
|
||||||
|
<span className="shrink-0 rounded-sm bg-muted px-1 text-[10px] text-muted-foreground">
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{!row.isPrimary ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => promote.mutate()}
|
||||||
|
disabled={promote.isPending}
|
||||||
|
title="Set as primary"
|
||||||
|
className={cn('h-6 px-1.5 text-[11px]')}
|
||||||
|
>
|
||||||
|
Make primary
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!row.isPrimary ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => remove.mutate()}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
aria-label="Remove contact"
|
||||||
|
title="Remove this contact"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddContactRow({
|
||||||
|
clientId,
|
||||||
|
channel,
|
||||||
|
existingCount,
|
||||||
|
invalidate,
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
channel: Channel;
|
||||||
|
existingCount: number;
|
||||||
|
invalidate: () => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [setPrimary, setSetPrimary] = useState(false);
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) throw new Error(`Enter a${channel === 'email' ? 'n email' : ' phone number'}.`);
|
||||||
|
await apiFetch(`/api/v1/clients/${clientId}/contacts`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { channel, value: v, isPrimary: setPrimary || existingCount === 0 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`${channel === 'email' ? 'Email' : 'Phone'} added`);
|
||||||
|
setValue('');
|
||||||
|
setSetPrimary(false);
|
||||||
|
setAdding(false);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adding) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1 w-full justify-start gap-1.5 text-xs"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Add {channel === 'email' ? 'an email' : 'a phone number'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-2 rounded-md border bg-muted/30 p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={`new-${channel}`} className="text-[11px] uppercase tracking-wide">
|
||||||
|
New {channel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`new-${channel}`}
|
||||||
|
type={channel === 'email' ? 'email' : 'tel'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 123 4567'}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setPrimary}
|
||||||
|
onChange={(e) => setSetPrimary(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Set as primary
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(false);
|
||||||
|
setValue('');
|
||||||
|
setSetPrimary(false);
|
||||||
|
}}
|
||||||
|
disabled={create.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
disabled={create.isPending || !value.trim()}
|
||||||
|
>
|
||||||
|
{create.isPending ? <Loader2 className="mr-1 h-3 w-3 animate-spin" aria-hidden /> : null}
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,14 @@ export interface ClientInterestRow {
|
|||||||
dateLastContact: string | null;
|
dateLastContact: string | null;
|
||||||
berthMooringNumber?: string | null;
|
berthMooringNumber?: string | null;
|
||||||
yachtName?: string | null;
|
yachtName?: string | null;
|
||||||
|
/** Requirements surfaced on the Client Overview panel — "Wants L × W × D
|
||||||
|
* · Source" lets reps see what the deal is looking for without drilling
|
||||||
|
* into the Interest detail. Fields are nullable when the rep hasn't
|
||||||
|
* captured constraints yet. */
|
||||||
|
desiredLengthFt?: string | null;
|
||||||
|
desiredWidthFt?: string | null;
|
||||||
|
desiredDraftFt?: string | null;
|
||||||
|
source?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InterestsResponse {
|
interface InterestsResponse {
|
||||||
@@ -314,6 +322,22 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
|
|||||||
{STAGE_LABELS[stage]}
|
{STAGE_LABELS[stage]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Requirements one-liner: "Wants 50ft × 18ft × 8ft · Referral".
|
||||||
|
Hidden when the rep hasn't captured any constraints yet —
|
||||||
|
noise reduction over empty placeholders. */}
|
||||||
|
{(() => {
|
||||||
|
const dims = [i.desiredLengthFt, i.desiredWidthFt, i.desiredDraftFt]
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
.map((v) => `${Number(v).toFixed(0)}ft`);
|
||||||
|
const summary: string[] = [];
|
||||||
|
if (dims.length > 0) summary.push(`Wants ${dims.join(' × ')}`);
|
||||||
|
if (i.source) summary.push(i.source);
|
||||||
|
return summary.length > 0 ? (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||||
|
{summary.join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<StageStepper current={stage} size="xs" />
|
<StageStepper current={stage} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -16,6 +18,13 @@ interface BerthsResponse {
|
|||||||
data: BerthRow[];
|
data: BerthRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CompetingInterest {
|
||||||
|
interestId: string;
|
||||||
|
clientName: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Surfaces when one of the interest's linked berths is sold or under offer
|
* Surfaces when one of the interest's linked berths is sold or under offer
|
||||||
* to a different deal. We don't block the rep from proceeding (the user
|
* to a different deal. We don't block the rep from proceeding (the user
|
||||||
@@ -38,34 +47,77 @@ export function InterestBerthStatusBanner({
|
|||||||
interestOutcome?: string | null;
|
interestOutcome?: string | null;
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
const { data } = useQuery<BerthsResponse>({
|
const { data } = useQuery<BerthsResponse>({
|
||||||
queryKey: ['interest-berths', interestId],
|
queryKey: ['interest-berths', interestId],
|
||||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
|
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const berths = data?.data ?? [];
|
||||||
|
const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer');
|
||||||
|
|
||||||
|
// Resolve the competing deal per conflicting berth via the
|
||||||
|
// `/active-interests` endpoint shipped in 292a8b5. Filtered client-side
|
||||||
|
// to interests OTHER THAN this one so a deal looking at its own berth
|
||||||
|
// doesn't see itself in the banner.
|
||||||
|
const competingQueries = useQueries({
|
||||||
|
queries: conflicts.map((b) => ({
|
||||||
|
queryKey: ['berth-competing', b.id, interestId] as const,
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`).then(
|
||||||
|
(r) => r.data.filter((row) => row.interestId !== interestId),
|
||||||
|
),
|
||||||
|
enabled: conflicts.length > 0,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
if (archivedAt || interestOutcome) return null;
|
if (archivedAt || interestOutcome) return null;
|
||||||
// The banner is most useful before the rep is committed to the deal —
|
// The banner is most useful before the rep is committed to the deal —
|
||||||
// once contract is in motion, the conflict is moot.
|
// once contract is in motion, the conflict is moot.
|
||||||
if (interestPipelineStage === 'contract') return null;
|
if (interestPipelineStage === 'contract') return null;
|
||||||
|
|
||||||
const berths = data?.data ?? [];
|
|
||||||
const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer');
|
|
||||||
if (conflicts.length === 0) return null;
|
if (conflicts.length === 0) return null;
|
||||||
|
|
||||||
|
const lines = conflicts.map((b, idx) => {
|
||||||
|
const q = competingQueries[idx];
|
||||||
|
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
|
||||||
|
return { berth: b, competing };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className="flex items-start gap-2 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-900"
|
className="flex items-start gap-2 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-900"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
|
<AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{conflicts.length === 1
|
{lines.length === 1
|
||||||
? `Berth ${conflicts[0]!.mooringNumber} is ${
|
? `Berth ${lines[0]!.berth.mooringNumber} is ${
|
||||||
conflicts[0]!.status === 'sold' ? 'Sold' : 'Under Offer'
|
lines[0]!.berth.status === 'sold' ? 'Sold' : 'Under Offer'
|
||||||
} to another deal.`
|
} to another deal.`
|
||||||
: `${conflicts.length} linked berths are no longer freely available.`}
|
: `${lines.length} linked berths are no longer freely available.`}
|
||||||
</p>
|
</p>
|
||||||
|
{lines.some((l) => l.competing) ? (
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{lines.map(({ berth, competing }) =>
|
||||||
|
competing ? (
|
||||||
|
<li key={berth.id} className="text-rose-900">
|
||||||
|
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
||||||
|
<Link
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
||||||
|
className="underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{competing.clientName}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
<p className="mt-0.5 text-rose-800">
|
<p className="mt-0.5 text-rose-800">
|
||||||
You can still progress this interest as a backup, but the rep on the other deal owns the
|
You can still progress this interest as a backup, but the rep on the other deal owns the
|
||||||
primary path. If their deal falls through, this one can step in.
|
primary path. If their deal falls through, this one can step in.
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
|
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
|
||||||
|
|
||||||
import { parsePhone } from '@/lib/i18n/phone';
|
|
||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
|
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { RemindersInline } from '@/components/reminders/reminders-inline';
|
import { RemindersInline } from '@/components/reminders/reminders-inline';
|
||||||
// Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
|
// Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
|
||||||
@@ -43,7 +42,9 @@ import {
|
|||||||
LEAD_CATEGORIES,
|
LEAD_CATEGORIES,
|
||||||
PIPELINE_STAGES,
|
PIPELINE_STAGES,
|
||||||
SOURCES,
|
SOURCES,
|
||||||
|
STAGE_BADGE,
|
||||||
canTransitionStage,
|
canTransitionStage,
|
||||||
|
stageLabel,
|
||||||
type PipelineStage,
|
type PipelineStage,
|
||||||
} from '@/lib/constants';
|
} from '@/lib/constants';
|
||||||
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
|
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
|
||||||
@@ -133,6 +134,8 @@ interface InterestTabsOptions {
|
|||||||
clientPrimaryEmailContactId?: string | null;
|
clientPrimaryEmailContactId?: string | null;
|
||||||
clientPrimaryPhone?: string | null;
|
clientPrimaryPhone?: string | null;
|
||||||
clientPrimaryPhoneContactId?: string | null;
|
clientPrimaryPhoneContactId?: string | null;
|
||||||
|
clientPrimaryPhoneE164?: string | null;
|
||||||
|
clientPrimaryPhoneCountry?: string | null;
|
||||||
dateFirstContact: string | null;
|
dateFirstContact: string | null;
|
||||||
dateLastContact: string | null;
|
dateLastContact: string | null;
|
||||||
dateEoiSent: string | null;
|
dateEoiSent: string | null;
|
||||||
@@ -605,8 +608,6 @@ function OverviewTab({
|
|||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
// QueryClient lifted to the top of the tab so the inline-edit email +
|
// QueryClient lifted to the top of the tab so the inline-edit email +
|
||||||
// phone handlers below can invalidate ['interest', id] on success.
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
// Lift the EOI generate dialog into the Overview so the milestone card
|
// Lift the EOI generate dialog into the Overview so the milestone card
|
||||||
// can launch it inline — same dialog the dedicated EOI tab uses, so the
|
// can launch it inline — same dialog the dedicated EOI tab uses, so the
|
||||||
// editing/confirmation flow is identical regardless of entry point.
|
// editing/confirmation flow is identical regardless of entry point.
|
||||||
@@ -1109,45 +1110,28 @@ function OverviewTab({
|
|||||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<EditableRow label="Email">
|
<EditableRow label="Email">
|
||||||
{interest.clientPrimaryEmailContactId ? (
|
{interest.clientId ? (
|
||||||
<InlineEditableField
|
<ClientChannelEditor
|
||||||
variant="text"
|
clientId={interest.clientId}
|
||||||
value={interest.clientPrimaryEmail ?? ''}
|
channel="email"
|
||||||
onSave={async (next) => {
|
primaryContactId={interest.clientPrimaryEmailContactId ?? null}
|
||||||
if (!interest.clientId || !interest.clientPrimaryEmailContactId) return;
|
primaryValue={interest.clientPrimaryEmail ?? null}
|
||||||
await apiFetch(
|
invalidateKeys={[['interest', interest.id]]}
|
||||||
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryEmailContactId}`,
|
|
||||||
{ method: 'PATCH', body: { value: next } },
|
|
||||||
);
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ['interest', interest.id],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Phone">
|
<EditableRow label="Phone">
|
||||||
{interest.clientPrimaryPhoneContactId ? (
|
{interest.clientId ? (
|
||||||
<InlineEditableField
|
<ClientChannelEditor
|
||||||
variant="text"
|
clientId={interest.clientId}
|
||||||
value={
|
channel="phone"
|
||||||
interest.clientPrimaryPhone
|
primaryContactId={interest.clientPrimaryPhoneContactId ?? null}
|
||||||
? (parsePhone(interest.clientPrimaryPhone).international ??
|
primaryValue={interest.clientPrimaryPhone ?? null}
|
||||||
interest.clientPrimaryPhone)
|
primaryValueE164={interest.clientPrimaryPhoneE164 ?? null}
|
||||||
: ''
|
primaryValueCountry={interest.clientPrimaryPhoneCountry ?? null}
|
||||||
}
|
invalidateKeys={[['interest', interest.id]]}
|
||||||
onSave={async (next) => {
|
|
||||||
if (!interest.clientId || !interest.clientPrimaryPhoneContactId) return;
|
|
||||||
await apiFetch(
|
|
||||||
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryPhoneContactId}`,
|
|
||||||
{ method: 'PATCH', body: { value: next } },
|
|
||||||
);
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ['interest', interest.id],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
@@ -1234,7 +1218,24 @@ function OverviewTab({
|
|||||||
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
|
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
|
||||||
{interest.recentNote.content}
|
{interest.recentNote.content}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{/* Stage pill = the deal's current stage. Source-of-truth
|
||||||
|
interpretation: the note is about the deal as it
|
||||||
|
stands today; reading it on Overview, "current stage"
|
||||||
|
answers the implicit "where in the deal is this?". A
|
||||||
|
historical "stage-at-note-time" lookup would need an
|
||||||
|
audit_logs read per teaser render — over-engineered for
|
||||||
|
a context hint. */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||||
|
STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
|
||||||
|
'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stageLabel(interest.pipelineStage)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
|
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
})}
|
})}
|
||||||
@@ -1245,6 +1246,7 @@ function OverviewTab({
|
|||||||
: (interest.recentNote.authorName ?? 'Unknown')
|
: (interest.recentNote.authorName ?? 'Unknown')
|
||||||
}`
|
}`
|
||||||
: ''}
|
: ''}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
id: clientContacts.id,
|
id: clientContacts.id,
|
||||||
value: clientContacts.value,
|
value: clientContacts.value,
|
||||||
valueE164: clientContacts.valueE164,
|
valueE164: clientContacts.valueE164,
|
||||||
|
valueCountry: clientContacts.valueCountry,
|
||||||
})
|
})
|
||||||
.from(clientContacts)
|
.from(clientContacts)
|
||||||
.where(
|
.where(
|
||||||
@@ -661,6 +662,7 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
clientPrimaryPhone: phoneContact?.value ?? null,
|
clientPrimaryPhone: phoneContact?.value ?? null,
|
||||||
clientPrimaryPhoneContactId: phoneContact?.id ?? null,
|
clientPrimaryPhoneContactId: phoneContact?.id ?? null,
|
||||||
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
||||||
|
clientPrimaryPhoneCountry: phoneContact?.valueCountry ?? null,
|
||||||
clientHasAddress: !!addressRow,
|
clientHasAddress: !!addressRow,
|
||||||
berthId,
|
berthId,
|
||||||
berthMooringNumber,
|
berthMooringNumber,
|
||||||
|
|||||||
Reference in New Issue
Block a user