Files
pn-new-crm/src/components/clients/client-tabs.tsx
Matt Ciaccio 16d98d630e feat(i18n): country/phone/timezone/subdivision primitives + form wiring
Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.

PR1  Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
     for localized labels, detectDefaultCountry() with navigator-region
     fallback to US, CountryCombobox with regional-indicator flag glyphs +
     compact mode for inline use.
PR2  Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
     callingCodeFor), PhoneInput with flag dropdown + national-format
     AsYouType + paste-detect that flips the country dropdown for pasted
     international strings.
PR3  Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
     ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
     TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4  Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
     codes for every country), per-country cache, SubdivisionCombobox with
     "Pick a country first" / "No regions available" empty states.
PR5  Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
     {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
     residentialClients {phone_e164, phone_country, nationality_iso, timezone,
     place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
     country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
     subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
     by every entity validator + route handler.
PR6  ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
     TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
     PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
     editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
     for the detail-page overview rows + ContactsEditor.
PR7  Residential client form + detail — phone -> PhoneInput, nationality/
     timezone/place-of-residence-country/subdivision rows in both create
     sheet and inline-editable detail view. Subdivision wipes when country
     flips since codes are country-scoped.
PR8  Company form + detail — incorporation country -> CountryCombobox,
     incorporation region -> SubdivisionCombobox in both modes.
PR9  Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
     and i18n fields from newer website builds, server-side parsePhone()
     fallback for legacy raw-international submissions. Old Nuxt builds
     keep working unchanged.

Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.

Test totals: vitest 713 -> 741 (+28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00

274 lines
8.1 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 { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries';
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 { ContactsEditor } from '@/components/clients/contacts-editor';
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' },
];
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;
}>;
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;
}>;
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="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="Nationality">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
await mutation.mutateAsync({ nationalityIso: iso });
}}
data-testid="client-nationality-inline"
/>
</EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
value={client.preferredLanguage}
onSave={save('preferredLanguage')}
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
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>
);
}
export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOptions): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab clientId={clientId} client={client} />,
},
{
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: 'interests',
label: 'Interests',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Interests will appear here once created.</p>
</div>
),
},
{
id: 'notes',
label: 'Notes',
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
},
{
id: 'files',
label: 'Files',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>File attachments coming soon.</p>
</div>
),
},
{
id: 'activity',
label: 'Activity',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Activity log coming soon.</p>
</div>
),
},
];
}