Files
pn-new-crm/src/components/clients/client-tabs.tsx
Matt 3e4d9d6310 feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00

316 lines
10 KiB
TypeScript

'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
import { ClientFilesTab } from '@/components/clients/client-files-tab';
import { ContactsEditor } from '@/components/clients/contacts-editor';
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
type ClientPatchField =
| 'fullName'
| 'nationality'
| 'nationalityIso'
| 'preferredContactMethod'
| 'preferredLanguage'
| 'timezone'
| 'source'
| 'sourceDetails';
const SOURCE_OPTIONS = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
{ value: 'other', label: 'Other' },
];
const CONTACT_METHOD_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'whatsapp', label: 'WhatsApp' },
];
function useClientPatch(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<ClientPatchField, string | null>>) => {
return apiFetch(`/api/v1/clients/${clientId}`, {
method: 'PATCH',
body: patch,
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['clients', clientId] });
},
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
interface ClientTabsOptions {
clientId: string;
currentUserId?: string;
client: {
fullName: string;
nationality?: string | null;
nationalityIso?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
contacts?: Array<{
id: string;
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string | null;
isPrimary: boolean;
}>;
addresses?: Address[];
yachts: Array<{
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
lengthFt: string | null;
widthFt: string | null;
status: string;
}>;
companies: Array<{
membershipId: string;
role: string;
isPrimary: boolean;
startDate: string | Date;
company: {
id: string;
name: string;
legalName: string | null;
status: string;
};
}>;
activeReservations: Array<{
id: string;
berthId: string;
yachtId: string;
startDate: string | Date;
tenureType: string;
status: string;
}>;
interestCount?: number;
noteCount?: number;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
function OverviewTab({
clientId,
client,
}: {
clientId: string;
client: ClientTabsOptions['client'];
}) {
const mutation = useClientPatch(clientId);
const save = (field: ClientPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Country">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
// Auto-default the timezone to the country's primary
// zone when none is set yet — saves the rep a click
// and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a
// value the rep deliberately picked.
const patch: { nationalityIso: string | null; timezone?: string | null } = {
nationalityIso: iso,
};
if (iso && !client.timezone) {
const defaultTz = primaryTimezoneFor(iso as CountryCode);
if (defaultTz) patch.timezone = defaultTz;
}
await mutation.mutateAsync(patch);
}}
data-testid="client-country-inline"
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={
client.timezone ??
(client.nationalityIso
? primaryTimezoneFor(client.nationalityIso as CountryCode)
: null)
}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</div>
{/* Contacts */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
</div>
{/* Source */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3>
<dl>
<EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
{/* Tags */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
</div>
</div>
</div>
);
}
export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOptions): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab clientId={clientId} client={client} />,
},
{
id: 'interests',
label: 'Interests',
badge: client.interestCount,
content: <ClientInterestsTab clientId={clientId} />,
},
{
id: 'yachts',
label: 'Yachts',
badge: client.yachts.length,
content: <ClientYachtsTab clientId={clientId} yachts={client.yachts} />,
},
{
id: 'companies',
label: 'Companies',
badge: client.companies.length,
content: <ClientCompaniesTab clientId={clientId} companies={client.companies} />,
},
{
id: 'reservations',
label: 'Reservations',
badge: client.activeReservations.length,
content: (
<ClientReservationsTab clientId={clientId} activeReservations={client.activeReservations} />
),
},
{
id: 'addresses',
label: 'Addresses',
badge: client.addresses?.length ?? 0,
content: (
<AddressesEditor
endpoint={`/api/v1/clients/${clientId}/addresses`}
invalidateKey={['clients', clientId]}
addresses={client.addresses ?? []}
/>
),
},
{
id: 'notes',
label: 'Notes',
badge: client.noteCount,
content: (
<NotesList
entityType="clients"
entityId={clientId}
currentUserId={currentUserId}
aggregate
/>
),
},
{
id: 'files',
label: 'Files',
content: <ClientFilesTab clientId={clientId} />,
},
{
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/clients/${clientId}/activity`}
emptyText="No activity recorded for this client yet."
/>
),
},
];
}