391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
|
|
import { ChevronDown, Loader2, Plus, Star, Trash2 } 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 { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||
|
|
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
|
||
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
|
|
import { apiFetch } from '@/lib/api/client';
|
||
|
|
import { toastError } from '@/lib/api/toast-error';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
import { parsePhone } from '@/lib/i18n/phone';
|
||
|
|
|
||
|
|
type Channel = 'email' | 'phone';
|
||
|
|
|
||
|
|
export interface ContactRow {
|
||
|
|
id: string;
|
||
|
|
channel: 'email' | 'phone' | 'whatsapp' | 'other';
|
||
|
|
value: string;
|
||
|
|
valueE164: string | null;
|
||
|
|
label: string | null;
|
||
|
|
isPrimary: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
clientId: string;
|
||
|
|
/**
|
||
|
|
* Channel filter — picker shows only `email` (or `phone` + `whatsapp` for
|
||
|
|
* phone-style channels). Edits / promotions stay scoped to the chosen
|
||
|
|
* channel.
|
||
|
|
*/
|
||
|
|
channel: Channel;
|
||
|
|
/** Server-resolved primary contact for this channel (drives the inline
|
||
|
|
* value rendering when the picker isn't open). */
|
||
|
|
primaryContactId: string | null;
|
||
|
|
primaryValue: string | null;
|
||
|
|
/** Phone channel only — E.164 form + ISO-3166-1 alpha-2 country code so the
|
||
|
|
* inline phone editor can preserve the national-format roundtrip. */
|
||
|
|
primaryValueE164?: string | null;
|
||
|
|
primaryValueCountry?: string | null;
|
||
|
|
/** Query keys to invalidate after any mutation succeeds — the parent
|
||
|
|
* detail view is usually keyed on `['interest', interestId]` or
|
||
|
|
* `['clients', clientId]` so the picker can't hard-code which to bump. */
|
||
|
|
invalidateKeys?: ReadonlyArray<readonly unknown[]>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Combobox-style editor for the Email + Phone rows on the Interest /
|
||
|
|
* Client Overview. Renders the current primary value with an inline
|
||
|
|
* edit-on-click; the chevron opens a popover listing every contact in
|
||
|
|
* the channel so the rep can:
|
||
|
|
* - promote a different contact to primary
|
||
|
|
* - edit any contact's value inline
|
||
|
|
* - add a new contact (defaults to non-primary; can be flagged primary
|
||
|
|
* at create time)
|
||
|
|
* - delete a non-primary contact
|
||
|
|
*
|
||
|
|
* Backed by:
|
||
|
|
* GET /api/v1/clients/[id]/contacts
|
||
|
|
* POST /api/v1/clients/[id]/contacts
|
||
|
|
* PATCH /api/v1/clients/[id]/contacts/[contactId]
|
||
|
|
* DELETE /api/v1/clients/[id]/contacts/[contactId]
|
||
|
|
* POST /api/v1/clients/[id]/contacts/[contactId]/promote-to-primary
|
||
|
|
*
|
||
|
|
* Phone channel shows `whatsapp` rows alongside `phone` so the picker
|
||
|
|
* works as the rep's "all the ways I can call this client" surface.
|
||
|
|
*/
|
||
|
|
export function ClientChannelEditor({
|
||
|
|
clientId,
|
||
|
|
channel,
|
||
|
|
primaryContactId,
|
||
|
|
primaryValue,
|
||
|
|
primaryValueE164,
|
||
|
|
primaryValueCountry,
|
||
|
|
invalidateKeys = [],
|
||
|
|
}: Props) {
|
||
|
|
const qc = useQueryClient();
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
|
||
|
|
const acceptedChannels: ContactRow['channel'][] =
|
||
|
|
channel === 'email' ? ['email'] : ['phone', 'whatsapp'];
|
||
|
|
|
||
|
|
const { data, isLoading } = useQuery<{ data: ContactRow[] }>({
|
||
|
|
queryKey: ['client-contacts', clientId, channel],
|
||
|
|
queryFn: () => apiFetch<{ data: ContactRow[] }>(`/api/v1/clients/${clientId}/contacts`),
|
||
|
|
enabled: open,
|
||
|
|
staleTime: 30_000,
|
||
|
|
});
|
||
|
|
|
||
|
|
const contacts = (data?.data ?? []).filter((c) => acceptedChannels.includes(c.channel));
|
||
|
|
|
||
|
|
function invalidate() {
|
||
|
|
void qc.invalidateQueries({ queryKey: ['client-contacts', clientId, channel] });
|
||
|
|
for (const key of invalidateKeys) {
|
||
|
|
void qc.invalidateQueries({ queryKey: key as unknown[] });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatPhone = (v: string) => parsePhone(v).international ?? v;
|
||
|
|
const displayValue = (row: ContactRow) =>
|
||
|
|
row.channel === 'phone' || row.channel === 'whatsapp' ? formatPhone(row.value) : row.value;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
{primaryContactId ? (
|
||
|
|
channel === 'phone' ? (
|
||
|
|
// Phone-specific editor: country picker + national-format input so
|
||
|
|
// the rep doesn't have to type the +44 / +1 prefix manually. The
|
||
|
|
// service auto-derives `valueE164` + `valueCountry` from the
|
||
|
|
// submitted shape, then the `value` (display) form is updated.
|
||
|
|
<InlinePhoneField
|
||
|
|
e164={primaryValueE164 ?? primaryValue ?? null}
|
||
|
|
country={primaryValueCountry ?? null}
|
||
|
|
onSave={async (next) => {
|
||
|
|
try {
|
||
|
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
body: {
|
||
|
|
value: next.e164 ?? '',
|
||
|
|
valueE164: next.e164 ?? null,
|
||
|
|
valueCountry: next.country,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
invalidate();
|
||
|
|
} catch (err) {
|
||
|
|
toastError(err);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<InlineEditableField
|
||
|
|
variant="text"
|
||
|
|
value={primaryValue ?? ''}
|
||
|
|
onSave={async (next) => {
|
||
|
|
try {
|
||
|
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
body: { value: next },
|
||
|
|
});
|
||
|
|
invalidate();
|
||
|
|
} catch (err) {
|
||
|
|
toastError(err);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
) : (
|
||
|
|
<span className="text-muted-foreground">-</span>
|
||
|
|
)}
|
||
|
|
<Popover open={open} onOpenChange={setOpen}>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||
|
|
aria-label={`More ${channel} contacts`}
|
||
|
|
>
|
||
|
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent align="start" className="w-80 p-2">
|
||
|
|
<div className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
|
|
{channel === 'email' ? 'Email contacts' : 'Phone & WhatsApp contacts'}
|
||
|
|
</div>
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
|
||
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||
|
|
Loading…
|
||
|
|
</div>
|
||
|
|
) : contacts.length === 0 ? (
|
||
|
|
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||
|
|
No {channel} contacts yet.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<ul className="divide-y">
|
||
|
|
{contacts.map((c) => (
|
||
|
|
<ContactRowItem
|
||
|
|
key={c.id}
|
||
|
|
clientId={clientId}
|
||
|
|
row={c}
|
||
|
|
invalidate={invalidate}
|
||
|
|
displayValue={displayValue(c)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
<AddContactRow
|
||
|
|
clientId={clientId}
|
||
|
|
channel={channel}
|
||
|
|
existingCount={contacts.length}
|
||
|
|
invalidate={invalidate}
|
||
|
|
/>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ContactRowItem({
|
||
|
|
clientId,
|
||
|
|
row,
|
||
|
|
displayValue,
|
||
|
|
invalidate,
|
||
|
|
}: {
|
||
|
|
clientId: string;
|
||
|
|
row: ContactRow;
|
||
|
|
displayValue: string;
|
||
|
|
invalidate: () => void;
|
||
|
|
}) {
|
||
|
|
const promote = useMutation({
|
||
|
|
mutationFn: async () => {
|
||
|
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}/promote-to-primary`, {
|
||
|
|
method: 'POST',
|
||
|
|
});
|
||
|
|
},
|
||
|
|
onSuccess: () => {
|
||
|
|
toast.success('Primary updated');
|
||
|
|
invalidate();
|
||
|
|
},
|
||
|
|
onError: (err) => toastError(err),
|
||
|
|
});
|
||
|
|
const remove = useMutation({
|
||
|
|
mutationFn: async () => {
|
||
|
|
await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}`, { method: 'DELETE' });
|
||
|
|
},
|
||
|
|
onSuccess: () => {
|
||
|
|
toast.success('Contact removed');
|
||
|
|
invalidate();
|
||
|
|
},
|
||
|
|
onError: (err) => toastError(err),
|
||
|
|
});
|
||
|
|
|
||
|
|
return (
|
||
|
|
<li className="px-1 py-1.5">
|
||
|
|
<div className="flex items-center justify-between gap-2">
|
||
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
||
|
|
{row.isPrimary ? (
|
||
|
|
<Star
|
||
|
|
className="h-3 w-3 shrink-0 text-amber-500"
|
||
|
|
aria-label="Primary"
|
||
|
|
aria-hidden={false}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<span className="h-3 w-3 shrink-0" aria-hidden />
|
||
|
|
)}
|
||
|
|
<span className="truncate text-sm">{displayValue}</span>
|
||
|
|
{row.label ? (
|
||
|
|
<span className="shrink-0 rounded-sm bg-muted px-1 text-[10px] text-muted-foreground">
|
||
|
|
{row.label}
|
||
|
|
</span>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-0.5">
|
||
|
|
{!row.isPrimary ? (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => promote.mutate()}
|
||
|
|
disabled={promote.isPending}
|
||
|
|
title="Set as primary"
|
||
|
|
className={cn('h-6 px-1.5 text-[11px]')}
|
||
|
|
>
|
||
|
|
Make primary
|
||
|
|
</Button>
|
||
|
|
) : null}
|
||
|
|
{!row.isPrimary ? (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||
|
|
onClick={() => remove.mutate()}
|
||
|
|
disabled={remove.isPending}
|
||
|
|
aria-label="Remove contact"
|
||
|
|
title="Remove this contact"
|
||
|
|
>
|
||
|
|
<Trash2 className="h-3 w-3" aria-hidden />
|
||
|
|
</Button>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function AddContactRow({
|
||
|
|
clientId,
|
||
|
|
channel,
|
||
|
|
existingCount,
|
||
|
|
invalidate,
|
||
|
|
}: {
|
||
|
|
clientId: string;
|
||
|
|
channel: Channel;
|
||
|
|
existingCount: number;
|
||
|
|
invalidate: () => void;
|
||
|
|
}) {
|
||
|
|
const [adding, setAdding] = useState(false);
|
||
|
|
const [value, setValue] = useState('');
|
||
|
|
const [setPrimary, setSetPrimary] = useState(false);
|
||
|
|
|
||
|
|
const create = useMutation({
|
||
|
|
mutationFn: async () => {
|
||
|
|
const v = value.trim();
|
||
|
|
if (!v) throw new Error(`Enter a${channel === 'email' ? 'n email' : ' phone number'}.`);
|
||
|
|
await apiFetch(`/api/v1/clients/${clientId}/contacts`, {
|
||
|
|
method: 'POST',
|
||
|
|
body: { channel, value: v, isPrimary: setPrimary || existingCount === 0 },
|
||
|
|
});
|
||
|
|
},
|
||
|
|
onSuccess: () => {
|
||
|
|
toast.success(`${channel === 'email' ? 'Email' : 'Phone'} added`);
|
||
|
|
setValue('');
|
||
|
|
setSetPrimary(false);
|
||
|
|
setAdding(false);
|
||
|
|
invalidate();
|
||
|
|
},
|
||
|
|
onError: (err) => toastError(err),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!adding) {
|
||
|
|
return (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
className="mt-1 w-full justify-start gap-1.5 text-xs"
|
||
|
|
onClick={() => setAdding(true)}
|
||
|
|
>
|
||
|
|
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||
|
|
Add {channel === 'email' ? 'an email' : 'a phone number'}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<div className="mt-1 space-y-2 rounded-md border bg-muted/30 p-2">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label htmlFor={`new-${channel}`} className="text-[11px] uppercase tracking-wide">
|
||
|
|
New {channel}
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id={`new-${channel}`}
|
||
|
|
type={channel === 'email' ? 'email' : 'tel'}
|
||
|
|
value={value}
|
||
|
|
onChange={(e) => setValue(e.target.value)}
|
||
|
|
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 123 4567'}
|
||
|
|
className="h-8 text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={setPrimary}
|
||
|
|
onChange={(e) => setSetPrimary(e.target.checked)}
|
||
|
|
/>
|
||
|
|
Set as primary
|
||
|
|
</label>
|
||
|
|
<div className="flex items-center justify-end gap-1">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => {
|
||
|
|
setAdding(false);
|
||
|
|
setValue('');
|
||
|
|
setSetPrimary(false);
|
||
|
|
}}
|
||
|
|
disabled={create.isPending}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => create.mutate()}
|
||
|
|
disabled={create.isPending || !value.trim()}
|
||
|
|
>
|
||
|
|
{create.isPending ? <Loader2 className="mr-1 h-3 w-3 animate-spin" aria-hidden /> : null}
|
||
|
|
Add
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|