UAT findings landed across the last few Playwright + React Grab passes; single grouped commit so the index doesn't fragment into 30 one-liners. User & auth: - `user-settings`: name now updates the avatar + topbar menu after save (was reading stale session). - `me/password-reset`: 3 bugs (token validation, error response shape, redirect chain). - Admin user permission-overrides route honours the same envelope as the rest of the admin surface. Dashboard: - Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card` (replaced by the customisable widget grid). - Strip `revenue_breakdown` from analytics route + use-analytics + service + integration test so nothing renders an empty card. - Activity log timeline overshoot fix (`interest-timeline` + `entity-activity-feed`). - Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile. - `dev-mode-banner`: derive dismissed state synchronously instead of via an effect (set-state-in-effect lint rule). Forms & lists (assorted polish): - client / company / yacht / interest / reminder forms — validation + empty-state copy + tab transitions. - companies/yachts list tweaks; berth recommender panel; qualification checklist; supplemental info request button. Infra & misc: - Queue workers (ai / email / notifications) — log shape + per-job timeout consistency. - Auth / brochures / users schema small adjustments; seeds reflect permissions matrix changes. - Scan shell + scanner manifest + AI admin page small fixes. - `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react` (recommended config from echarts-for-react inside Next). Docs: - `docs/superpowers/audits/alpha-uat-master.md` — single rolling cross-cutting UAT findings doc (per CLAUDE.md convention). - `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log normalization (§J). - 2026-05-18 audit log updated with this batch. - `CLAUDE.md` — small manual UAT scaffold notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
703 lines
29 KiB
TypeScript
703 lines
29 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useForm, useFieldArray } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Plus, Trash2, Loader2 } 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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { TagPicker } from '@/components/shared/tag-picker';
|
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
|
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
|
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
|
import { PhoneInput } from '@/components/shared/phone-input';
|
|
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import type { z } from 'zod';
|
|
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
|
import { SOURCES } from '@/lib/constants';
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
|
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
|
|
|
interface ClientFormProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** Optional callback fired when the dedup suggestion panel reports
|
|
* the user picked an existing client. The form closes; parent is
|
|
* responsible for navigating to the existing client's detail page
|
|
* or opening the create-interest dialog pre-filled with that
|
|
* clientId. Skipped in edit mode. */
|
|
onUseExistingClient?: (clientId: string) => void;
|
|
/** Optional initial values for the create flow — used by the
|
|
* inquiry-inbox "Convert to client" triage step (P-4.5) so the rep
|
|
* doesn't retype values they just read in the inbox. The
|
|
* `sourceInquiryId` is persisted to `clients.source_inquiry_id` on
|
|
* save, preserving the inquiry → client lineage for reporting. */
|
|
prefill?: {
|
|
fullName?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
source?: 'website' | 'manual' | 'referral' | 'broker' | 'other';
|
|
sourceInquiryId?: string;
|
|
};
|
|
/** If provided, form is in edit mode */
|
|
client?: {
|
|
id: string;
|
|
fullName: string;
|
|
nationalityIso?: string | null;
|
|
preferredContactMethod?: string | null;
|
|
preferredLanguage?: string | null;
|
|
timezone?: string | null;
|
|
source?: string | null;
|
|
sourceDetails?: string | null;
|
|
contacts?: Array<{
|
|
channel: string;
|
|
value: string;
|
|
valueE164?: string | null;
|
|
valueCountry?: string | null;
|
|
label?: string | null;
|
|
isPrimary?: boolean;
|
|
notes?: string | null;
|
|
}>;
|
|
tags?: Array<{ id: string }>;
|
|
};
|
|
}
|
|
|
|
export function ClientForm({
|
|
open,
|
|
onOpenChange,
|
|
client,
|
|
onUseExistingClient,
|
|
prefill,
|
|
}: ClientFormProps) {
|
|
const queryClient = useQueryClient();
|
|
const isEdit = !!client;
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
control,
|
|
watch,
|
|
setValue,
|
|
getValues,
|
|
reset,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<z.input<typeof createClientSchema>, unknown, CreateClientInput>({
|
|
resolver: zodResolver(createClientSchema),
|
|
defaultValues: {
|
|
fullName: '',
|
|
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
|
tagIds: [],
|
|
},
|
|
});
|
|
|
|
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
|
const tagIds = watch('tagIds') ?? [];
|
|
|
|
// Primary-address fields. Live outside RHF because the API splits
|
|
// client creation (`POST /api/v1/clients`) from address creation
|
|
// (`POST /api/v1/clients/{id}/addresses`) — the address gets chained
|
|
// after the client POST returns the new id. Edit mode uses the
|
|
// dedicated Addresses tab; the form here is create-only.
|
|
const [addressOpen, setAddressOpen] = useState(false);
|
|
const [addrStreet, setAddrStreet] = useState('');
|
|
const [addrCity, setAddrCity] = useState('');
|
|
const [addrSubdivisionIso, setAddrSubdivisionIso] = useState<string | null>(null);
|
|
const [addrPostal, setAddrPostal] = useState('');
|
|
const [addrCountryIso, setAddrCountryIso] = useState<string | null>(null);
|
|
|
|
// When the rep picks a country and no timezone is set yet, pre-fill the
|
|
// timezone with the country's primary IANA zone. Skips when the user has
|
|
// already chosen a zone explicitly so we never clobber a deliberate pick.
|
|
const watchedNationality = watch('nationalityIso');
|
|
const watchedTimezone = watch('timezone');
|
|
useEffect(() => {
|
|
if (!watchedNationality || watchedTimezone) return;
|
|
const primary = primaryTimezoneFor(watchedNationality as CountryCode);
|
|
if (primary) setValue('timezone', primary);
|
|
}, [watchedNationality, watchedTimezone, setValue]);
|
|
|
|
// Populate form when editing
|
|
useEffect(() => {
|
|
if (client && open) {
|
|
reset({
|
|
fullName: client.fullName,
|
|
nationalityIso: client.nationalityIso ?? undefined,
|
|
preferredContactMethod:
|
|
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??
|
|
undefined,
|
|
preferredLanguage: client.preferredLanguage ?? undefined,
|
|
timezone: client.timezone ?? undefined,
|
|
source: (client.source as CreateClientInput['source']) ?? undefined,
|
|
sourceDetails: client.sourceDetails ?? undefined,
|
|
contacts:
|
|
client.contacts && client.contacts.length > 0
|
|
? client.contacts.map((c) => ({
|
|
channel: c.channel as 'email' | 'phone' | 'whatsapp' | 'other',
|
|
value: c.value,
|
|
valueE164: c.valueE164 ?? undefined,
|
|
valueCountry: c.valueCountry ?? undefined,
|
|
label: c.label ?? undefined,
|
|
isPrimary: c.isPrimary ?? false,
|
|
notes: c.notes ?? undefined,
|
|
}))
|
|
: [{ channel: 'email', value: '', isPrimary: true }],
|
|
tagIds: client.tags?.map((t) => t.id) ?? [],
|
|
});
|
|
} else if (!client && open) {
|
|
// P-4.5: when the inquiry-inbox triage flow opens the form via
|
|
// `?create=1&prefill_*`, hydrate the initial values so the rep
|
|
// doesn't retype data they just reviewed. `sourceInquiryId`
|
|
// gets persisted on save (clients.source_inquiry_id column) so
|
|
// the inquiry → client lineage survives for the conversion-
|
|
// funnel chart.
|
|
const contacts: CreateClientInput['contacts'] = [];
|
|
if (prefill?.email) {
|
|
contacts.push({ channel: 'email', value: prefill.email, isPrimary: true });
|
|
}
|
|
if (prefill?.phone) {
|
|
contacts.push({
|
|
channel: 'phone',
|
|
value: prefill.phone,
|
|
isPrimary: contacts.length === 0,
|
|
});
|
|
}
|
|
if (contacts.length === 0) {
|
|
contacts.push({ channel: 'email', value: '', isPrimary: true });
|
|
}
|
|
reset({
|
|
fullName: prefill?.fullName ?? '',
|
|
contacts,
|
|
// Default a manually-created client to `source='manual'` so reps
|
|
// don't have to remember to pick it. The inquiry-inbox triage
|
|
// flow overrides this via `prefill.source='website'`; the global
|
|
// command-search quick-create has no `prefill.source` and
|
|
// therefore correctly lands on 'manual'.
|
|
source: prefill?.source ?? 'manual',
|
|
sourceInquiryId: prefill?.sourceInquiryId,
|
|
tagIds: [],
|
|
});
|
|
setAddressOpen(false);
|
|
setAddrStreet('');
|
|
setAddrCity('');
|
|
setAddrSubdivisionIso(null);
|
|
setAddrPostal('');
|
|
setAddrCountryIso(null);
|
|
}
|
|
}, [client, open, reset, prefill]);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (data: CreateClientInput) => {
|
|
// F19: drop contact rows whose value is empty/whitespace before
|
|
// submitting. The form pre-adds an "empty primary" contact row
|
|
// for convenience; reps who only want to record a name shouldn't
|
|
// be forced to either fill it or delete it.
|
|
const cleanedContacts = (data.contacts ?? []).filter(
|
|
(c) => typeof c.value === 'string' && c.value.trim().length > 0,
|
|
);
|
|
if (cleanedContacts.length === 0) {
|
|
// The API still requires ≥1 contact. The form-level required
|
|
// marker on the email input also fires HTML5 validation; this
|
|
// is the fall-back if the rep wiped the value after focus.
|
|
throw Object.assign(new Error('At least one contact is required.'), { status: 400 });
|
|
}
|
|
// Primary is per-channel (DB has a partial unique index on
|
|
// (client_id, channel) WHERE is_primary). For every channel present
|
|
// in the cleaned set, ensure exactly one row is flagged primary —
|
|
// promote the first row of that channel if none was explicitly
|
|
// marked, and clear duplicates so the API doesn't 409.
|
|
const seenPrimaryByChannel = new Set<string>();
|
|
for (const c of cleanedContacts) {
|
|
if (c.isPrimary && !seenPrimaryByChannel.has(c.channel)) {
|
|
seenPrimaryByChannel.add(c.channel);
|
|
} else if (c.isPrimary) {
|
|
// duplicate primary within the channel — clear
|
|
c.isPrimary = false;
|
|
}
|
|
}
|
|
const seenChannels = new Set<string>(cleanedContacts.map((c) => c.channel));
|
|
for (const channel of seenChannels) {
|
|
if (seenPrimaryByChannel.has(channel)) continue;
|
|
const first = cleanedContacts.find((c) => c.channel === channel);
|
|
if (first) first.isPrimary = true;
|
|
}
|
|
const payload: CreateClientInput = { ...data, contacts: cleanedContacts };
|
|
|
|
if (isEdit) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { contacts, tagIds: tIds, ...rest } = payload;
|
|
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
|
|
if (tIds) {
|
|
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
|
|
method: 'PUT',
|
|
body: { tagIds: tIds },
|
|
});
|
|
}
|
|
} else {
|
|
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
|
|
method: 'POST',
|
|
body: payload,
|
|
});
|
|
// Chain the address POST when any field is filled. Address errors
|
|
// don't unwind the client create — surface a toast warning and
|
|
// leave the client in place so the rep can finish in the
|
|
// Addresses tab.
|
|
const hasAddress =
|
|
addrStreet.trim() ||
|
|
addrCity.trim() ||
|
|
addrPostal.trim() ||
|
|
addrSubdivisionIso ||
|
|
addrCountryIso;
|
|
if (hasAddress) {
|
|
try {
|
|
await apiFetch(`/api/v1/clients/${res.data.id}/addresses`, {
|
|
method: 'POST',
|
|
body: {
|
|
streetAddress: addrStreet.trim() || null,
|
|
city: addrCity.trim() || null,
|
|
subdivisionIso: addrSubdivisionIso ?? undefined,
|
|
postalCode: addrPostal.trim() || null,
|
|
countryIso: addrCountryIso ?? undefined,
|
|
isPrimary: true,
|
|
},
|
|
});
|
|
} catch {
|
|
toast.error(
|
|
'Client created but the address could not be saved. Add it from the Addresses tab.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
|
// M-U10: confirm the write landed. Without this the rep closes
|
|
// the sheet not sure whether the create/edit actually saved.
|
|
toast.success(isEdit ? 'Client updated' : 'Client created');
|
|
onOpenChange(false);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
|
<SheetHeader>
|
|
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
// A4: prune empty contact rows BEFORE handleSubmit/zod runs.
|
|
// The schema requires `value: z.string().min(1)`, so an empty
|
|
// row (the form pre-adds one for convenience) silently fails
|
|
// form validation with no visible error. Strip them first so
|
|
// the rest of the validation sees only real rows.
|
|
const current = getValues('contacts') ?? [];
|
|
const cleaned = current.filter(
|
|
(c) => typeof c?.value === 'string' && c.value.trim().length > 0,
|
|
);
|
|
if (cleaned.length !== current.length) {
|
|
setValue('contacts', cleaned, { shouldValidate: false });
|
|
}
|
|
return handleSubmit((data) => mutation.mutate(data))(e);
|
|
}}
|
|
className="space-y-6 py-6"
|
|
>
|
|
{/* Dedup suggestion - only on the create path. Watches the
|
|
live form values for email / phone / name and surfaces
|
|
an existing client when one matches. The user can
|
|
attach the new interest to that client instead of
|
|
creating a duplicate. */}
|
|
{!isEdit ? (
|
|
<DedupSuggestionPanel
|
|
email={watch('contacts')?.find((c) => c?.channel === 'email')?.value ?? null}
|
|
phone={
|
|
watch('contacts')?.find((c) => c?.channel === 'phone' || c?.channel === 'whatsapp')
|
|
?.valueE164 ?? null
|
|
}
|
|
name={watch('fullName') ?? null}
|
|
onUseExisting={(match) => {
|
|
onUseExistingClient?.(match.clientId);
|
|
onOpenChange(false);
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Basic Info */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Basic Information
|
|
</h3>
|
|
|
|
<div className="space-y-1">
|
|
<Label>Full Name *</Label>
|
|
<Input {...register('fullName')} placeholder="John Smith" />
|
|
{errors.fullName && (
|
|
<p className="text-xs text-destructive">{errors.fullName.message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Contacts */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Contacts
|
|
</h3>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => append({ channel: 'email', value: '', isPrimary: false })}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden />
|
|
Add Contact
|
|
</Button>
|
|
</div>
|
|
|
|
{errors.contacts?.root && (
|
|
<p className="text-xs text-destructive">{errors.contacts.root.message}</p>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{fields.map((field, index) => (
|
|
<div key={field.id} className="space-y-3 p-3 rounded-lg border bg-muted/30">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-start sm:gap-2">
|
|
<div className="space-y-1 sm:col-span-3">
|
|
<Label className="text-xs">Channel</Label>
|
|
<Select
|
|
value={watch(`contacts.${index}.channel`)}
|
|
onValueChange={(v) =>
|
|
setValue(
|
|
`contacts.${index}.channel`,
|
|
v as 'email' | 'phone' | 'whatsapp' | 'other',
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9 sm:h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="email">Email</SelectItem>
|
|
<SelectItem value="phone">Phone</SelectItem>
|
|
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
|
<SelectItem value="other">Other</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1 sm:col-span-5">
|
|
<Label className="text-xs">Value</Label>
|
|
{(() => {
|
|
const channel = watch(`contacts.${index}.channel`);
|
|
if (channel === 'phone' || channel === 'whatsapp') {
|
|
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
|
|
const country =
|
|
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
|
|
undefined;
|
|
return (
|
|
<PhoneInput
|
|
value={
|
|
e164 || country
|
|
? {
|
|
e164: e164 ?? null,
|
|
country: country ?? 'US',
|
|
}
|
|
: null
|
|
}
|
|
onChange={(v) => {
|
|
setValue(`contacts.${index}.value`, v.e164 ?? '');
|
|
setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined);
|
|
setValue(`contacts.${index}.valueCountry`, v.country);
|
|
}}
|
|
data-testid={`contact-${index}-phone`}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<Input
|
|
{...register(`contacts.${index}.value`)}
|
|
className="h-9 sm:h-8"
|
|
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
<div className="space-y-1 sm:col-span-4">
|
|
<Label className="text-xs">
|
|
{watch(`contacts.${index}.channel`) === 'other' ? 'Specify' : 'Label'}
|
|
</Label>
|
|
<Input
|
|
{...register(`contacts.${index}.label`)}
|
|
className="h-9 sm:h-8"
|
|
placeholder={
|
|
watch(`contacts.${index}.channel`) === 'other'
|
|
? 'e.g. Telegram, Signal'
|
|
: 'work'
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom strip: Primary toggle left, delete right. Sits on
|
|
its own row on every breakpoint so neither control gets
|
|
squashed by the field columns above. */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
|
<Checkbox
|
|
checked={watch(`contacts.${index}.isPrimary`)}
|
|
onCheckedChange={(v) => {
|
|
const checked = !!v;
|
|
const thisChannel = watch(`contacts.${index}.channel`);
|
|
if (checked) {
|
|
// Primary is per-channel — flipping this one on
|
|
// clears the flag on every other row sharing the
|
|
// same channel. (DB enforces uniqueness via a
|
|
// partial index, but doing it client-side avoids
|
|
// a surprising 409 mid-save.)
|
|
const all = getValues('contacts') ?? [];
|
|
const next = all.map((c, i) => ({
|
|
...c,
|
|
isPrimary:
|
|
i === index
|
|
? true
|
|
: c.channel === thisChannel
|
|
? false
|
|
: !!c.isPrimary,
|
|
}));
|
|
setValue('contacts', next, { shouldDirty: true });
|
|
} else {
|
|
setValue(`contacts.${index}.isPrimary`, false, { shouldDirty: true });
|
|
}
|
|
}}
|
|
/>
|
|
<span className="font-medium">
|
|
Primary{' '}
|
|
{watch(`contacts.${index}.channel`) === 'email'
|
|
? 'email'
|
|
: watch(`contacts.${index}.channel`) === 'phone'
|
|
? 'phone'
|
|
: watch(`contacts.${index}.channel`) === 'whatsapp'
|
|
? 'WhatsApp'
|
|
: 'contact'}
|
|
</span>
|
|
</label>
|
|
{fields.length > 1 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-destructive hover:text-destructive"
|
|
onClick={() => remove(index)}
|
|
>
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Source & Preferences */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Source & Preferences
|
|
</h3>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1">
|
|
<Label>Source</Label>
|
|
<Select
|
|
value={watch('source') ?? ''}
|
|
onValueChange={(v) =>
|
|
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker' | 'other')
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select source" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SOURCES.map((s) => (
|
|
<SelectItem key={s.value} value={s.value}>
|
|
{s.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Preferred Contact Method</Label>
|
|
<Select
|
|
value={watch('preferredContactMethod') ?? ''}
|
|
onValueChange={(v) =>
|
|
setValue('preferredContactMethod', v as 'email' | 'phone' | 'whatsapp')
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select method" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="email">Email</SelectItem>
|
|
<SelectItem value="phone">Phone</SelectItem>
|
|
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Country</Label>
|
|
<CountryCombobox
|
|
value={watch('nationalityIso')}
|
|
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
|
|
data-testid="client-nationality"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Timezone</Label>
|
|
<TimezoneCombobox
|
|
value={watch('timezone')}
|
|
onChange={(tz) => setValue('timezone', tz ?? undefined)}
|
|
countryHint={(watch('nationalityIso') as CountryCode | undefined) ?? undefined}
|
|
data-testid="client-timezone"
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2 space-y-1">
|
|
<Label>Source Details</Label>
|
|
<Input {...register('sourceDetails')} placeholder="Referred by John Doe" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Primary Address — create-only. Editing happens in the
|
|
client detail page's Addresses tab. */}
|
|
{!isEdit ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
Primary Address
|
|
</h3>
|
|
{!addressOpen ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setAddressOpen(true)}
|
|
>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
Add address
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setAddressOpen(false);
|
|
setAddrStreet('');
|
|
setAddrCity('');
|
|
setAddrSubdivisionIso(null);
|
|
setAddrPostal('');
|
|
setAddrCountryIso(null);
|
|
}}
|
|
>
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{addressOpen ? (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="sm:col-span-2 space-y-1">
|
|
<Label>Street address</Label>
|
|
<Input
|
|
value={addrStreet}
|
|
onChange={(e) => setAddrStreet(e.target.value)}
|
|
placeholder="123 Marina Way, Suite 4"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>City</Label>
|
|
<Input
|
|
value={addrCity}
|
|
onChange={(e) => setAddrCity(e.target.value)}
|
|
placeholder="Anguilla"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Postal code</Label>
|
|
<Input
|
|
value={addrPostal}
|
|
onChange={(e) => setAddrPostal(e.target.value)}
|
|
placeholder="AI-2640"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Country</Label>
|
|
<CountryCombobox
|
|
value={addrCountryIso}
|
|
onChange={(iso) => {
|
|
setAddrCountryIso(iso ?? null);
|
|
// Clear region if country changes — keeps the
|
|
// subdivision picker consistent with its country.
|
|
setAddrSubdivisionIso(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Region / State</Label>
|
|
<SubdivisionCombobox
|
|
country={addrCountryIso as CountryCode | null}
|
|
value={addrSubdivisionIso}
|
|
onChange={(iso) => setAddrSubdivisionIso(iso)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{!isEdit ? <Separator /> : null}
|
|
|
|
{/* Tags */}
|
|
<div className="space-y-2">
|
|
<Label>Tags</Label>
|
|
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
|
</div>
|
|
|
|
<SheetFooter>
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
|
{(isSubmitting || mutation.isPending) && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
)}
|
|
{isEdit ? 'Save changes' : 'Create Client'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|