Phase 3 — EOI overrides (now ☑):
- Address override field with the same per-component input UX as the
canonical address form (line1/line2/city/state/postal + ISO
subdivision + CountryCombobox). Two-checkbox intent semantics
identical to email/phone — useOnlyForThisEoi writes only to
documents.override_client_address_* columns; setAsDefault promotes
to the canonical client_addresses primary inside the override
transaction; neither flag inserts a non-primary address row for
future reuse. eoi-context route now returns available.addresses so
the dialog can render the picker over existing rows.
- yachts.source_document_id backfill — yachts spawned via EOI run
BEFORE generateAndSign creates the document row, so source_document_id
stayed NULL. Mirrored the bounded-recent backfill pattern from
contacts into persistDocumentOverrides for both client_addresses and
yachts (every row inserted in the last 60s with NULL source_document_id
and the right source flag gets attributed).
- Audit-log filter chips for the new verbs — eoi_field_override,
promote_to_primary, eoi_spawn_yacht now appear in /admin/audit
dropdown + get human labels in the card view.
Phase 4 — reminders inline section (now ☑):
- New <RemindersInline> shared component shows the 3-5 most recent
open reminders for an entity. Mounted on Overview tab of yacht /
client / interest detail. Empty state hints at the header button
rather than duplicating it.
Phase 5 — email tone (now ☑ across all 8 templates):
- admin-email-change, crm-invite, inquiry-sales-notification,
residential-inquiry — voice + sign-off match the 4 shipped earlier
("Dear X", "With warm regards, The {portName} Team", sentence-case
subjects). Snapshot tests deferred — they'd need a 2nd-port fixture
set up to catch port-name leaks; templates are correct in review.
Phase 7 — PDF editor (now ☑):
- 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes"
badge), ResizeObserver-driven responsive PDF width, required-tokens-
unplaced indicator reading template.mergeFields.
- 7.2 drag-to-move with on-page clamping.
- 7.2 four-corner resize handles with min-size enforcement.
- 7.2 right-click context delete via onContextMenu.
- 7.2 multi-page navigation + per-page marker filter.
- 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview
runs the in-app pdf-lib fill against the supplied interest, uploads
to a transient previews/ key, returns a 15-min presigned URL.
- 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf
takes multipart FormData, magic-byte verifies %PDF-, parses page
count via pdf-lib, swaps documentTemplates.sourceFileId. Editor
warns when the new page count truncates the prior set.
Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
311 lines
9.9 KiB
TypeScript
311 lines
9.9 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 { RemindersInline } from '@/components/reminders/reminders-inline';
|
|
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';
|
|
import { SOURCES } from '@/lib/constants';
|
|
|
|
type ClientPatchField =
|
|
| 'fullName'
|
|
| 'nationality'
|
|
| 'nationalityIso'
|
|
| 'preferredContactMethod'
|
|
| 'preferredLanguage'
|
|
| 'timezone'
|
|
| 'source'
|
|
| 'sourceDetails';
|
|
|
|
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
|
|
|
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>
|
|
|
|
<InlineTagEditor
|
|
heading="Tags"
|
|
endpoint={`/api/v1/clients/${clientId}/tags`}
|
|
currentTags={client.tags ?? []}
|
|
invalidateKey={['clients', clientId]}
|
|
/>
|
|
|
|
<RemindersInline clientId={clientId} />
|
|
</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
|
|
aggregate
|
|
entityType="clients"
|
|
entityId={clientId}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
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."
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
}
|