Files
pn-new-crm/src/components/clients/client-form.tsx
Matt 689a114aba fix(audit-wave-9): copy/terminology sweep (copy-auditor)
Address the highest-impact items from the copy-auditor's CRITICAL +
HIGH + MEDIUM bands:

**C2 portal raw-status leak**
- Drop the staff-only `leadCategory` chip from the portal interests
  page entirely. Privacy + optics: clients should never see "hot lead"
  in their own portal. `eoiStatus` was already wrapped in
  `portalSigningLabel`; only the categorical chip remained.

**C3 signing-status label drift**
- Add `src/lib/labels/document-status.ts` as the single source of
  truth for the {draft, sent, partially_signed, completed, expired,
  cancelled} lifecycle: labels (CRM + portal variants), StatusPill
  variant, and the "active / in-flight" set.
- Wire it into interest-eoi-tab, interest-contract-tab,
  interest-reservation-tab — they previously redefined identical
  STATUS_LABELS / ACTIVE_STATUSES blocks per-file.

**H1 + M3 verbiage codemod**
- `Save Changes` → `Save changes` (sentence case, matches the
  surrounding admin/CRM pattern).
- `Saving...` (ASCII three dots) → `Saving…` (Unicode ellipsis).
  Matches the project's UTF-8-elsewhere convention and reads
  correctly via screen-readers.

**M1 envelope jargon → signing request**
- smart-archive-dialog: "Leave envelope pending" → "Leave signing
  request pending"; "Void the signing envelope" → "Cancel the signing
  request"; section header updated to match.
- document-detail: "voids the signing envelope" → "cancels the signing
  request".
- bulk-archive-wizard: "leave invoices/signing envelopes alone" →
  "leave invoices/signing requests alone".
- Documenso admin page intentionally keeps `envelope` (dev/integration
  vocabulary).

**M5 Hot Lead casing**
- Normalize `Hot Lead` / `General Interest` / `Specific Qualified` to
  sentence case in `constants.ts` LABEL_OVERRIDES and all per-file
  lead-category maps so the CRM trend (sentence case) is consistent.

**C1 surface-level rename**
- "Linked prospect (optional)" → "Linked interest (optional)" on the
  berth status-change dialog.
- "Deal Documents" tab → "Interest Documents" (URL/route kept as
  `/deal-documents` to avoid breaking deep links; rename deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:12:40 +02:00

432 lines
17 KiB
TypeScript

'use client';
import { useEffect } 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 { 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 { 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;
/** 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 }: ClientFormProps) {
const queryClient = useQueryClient();
const isEdit = !!client;
const {
register,
handleSubmit,
control,
watch,
setValue,
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') ?? [];
// 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) {
reset({
fullName: '',
contacts: [{ channel: 'email', value: '', isPrimary: true }],
tagIds: [],
});
}
}, [client, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateClientInput) => {
if (isEdit) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { contacts, tagIds: tIds, ...rest } = data;
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 {
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
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={handleSubmit((data) => mutation.mutate(data))} 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="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2 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 className="space-y-1">
<Label>Country</Label>
<CountryCombobox
value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
data-testid="client-nationality"
/>
</div>
</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" />
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-end 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) => setValue(`contacts.${index}.isPrimary`, !!v)}
/>
<span className="font-medium">Primary 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" />
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>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 />
{/* 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" />
)}
{isEdit ? 'Save changes' : 'Create Client'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}