feat(intake): recipient picker UI (users/roles/everyone/emails)
Adds RecipientPicker (multi-select users + roles, everyone-with-inquiry-access toggle, free-text emails) and a new 'recipients' settings field type. The inquiry + residential notification-recipient settings now render the picker instead of a raw JSON textarea, persisting the structured {emails,userIds,roleIds,everyone} config the server resolver expands. tsc clean; full vitest suite (1570) green. Live browser verification of the picker pending a dev server (env currently on the prod build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
242
src/components/admin/settings/recipient-picker.tsx
Normal file
242
src/components/admin/settings/recipient-picker.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, ChevronsUpDown, Save, X } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipientConfigValue {
|
||||||
|
emails: string[];
|
||||||
|
userIds: string[];
|
||||||
|
roleIds: string[];
|
||||||
|
everyone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client mirror of the server's parseRecipientConfig
|
||||||
|
* (`src/lib/services/notification-recipients.ts`) - kept inline because that
|
||||||
|
* module imports server-only deps (drizzle/db). A legacy `string[]` is treated
|
||||||
|
* as explicit emails.
|
||||||
|
*/
|
||||||
|
function parseValue(value: unknown): RecipientConfigValue {
|
||||||
|
const strArr = (v: unknown): string[] =>
|
||||||
|
Array.isArray(v)
|
||||||
|
? v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return { emails: strArr(value), userIds: [], roleIds: [], everyone: false };
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const o = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
emails: strArr(o.emails),
|
||||||
|
userIds: strArr(o.userIds),
|
||||||
|
roleIds: strArr(o.roleIds),
|
||||||
|
everyone: o.everyone === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { emails: [], userIds: [], roleIds: [], everyone: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelect({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyText,
|
||||||
|
}: {
|
||||||
|
options: Option[];
|
||||||
|
selected: string[];
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
placeholder: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
emptyText: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const byId = new Map(options.map((o) => [o.id, o.label]));
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selected.map((id) => (
|
||||||
|
<Badge key={id} variant="secondary" className="gap-1">
|
||||||
|
{byId.get(id) ?? id.slice(0, 8)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(id)}
|
||||||
|
className="ml-0.5 rounded-sm hover:bg-muted-foreground/20"
|
||||||
|
aria-label={`Remove ${byId.get(id) ?? id}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Popover open={open} onOpenChange={setOpen} modal>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full justify-between sm:w-72"
|
||||||
|
>
|
||||||
|
<span className="truncate text-muted-foreground">{placeholder}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((o) => (
|
||||||
|
<CommandItem key={o.id} value={o.label} onSelect={() => toggle(o.id)}>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selected.includes(o.id) ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{o.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin control for an inquiry notification-recipient setting. Edits the
|
||||||
|
* structured `{emails,userIds,roleIds,everyone}` config (or a legacy email
|
||||||
|
* array) and persists via the parent's `onSave`. The server resolver expands
|
||||||
|
* users/roles/everyone into concrete addresses at send time.
|
||||||
|
*/
|
||||||
|
export function RecipientPicker({
|
||||||
|
value,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: (config: RecipientConfigValue) => void;
|
||||||
|
}) {
|
||||||
|
const initial = parseValue(value);
|
||||||
|
const [everyone, setEveryone] = useState(initial.everyone);
|
||||||
|
const [userIds, setUserIds] = useState<string[]>(initial.userIds);
|
||||||
|
const [roleIds, setRoleIds] = useState<string[]>(initial.roleIds);
|
||||||
|
const [emailsText, setEmailsText] = useState(initial.emails.join('\n'));
|
||||||
|
|
||||||
|
const { data: usersData } = useQuery<{ data: { id: string; displayName: string | null }[] }>({
|
||||||
|
queryKey: ['recipient-user-options'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/admin/users/options'),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
const { data: rolesData } = useQuery<{ data: { id: string; name: string }[] }>({
|
||||||
|
queryKey: ['recipient-role-options'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/admin/roles'),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userOptions: Option[] = (usersData?.data ?? []).map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
label: u.displayName ?? u.id.slice(0, 8),
|
||||||
|
}));
|
||||||
|
const roleOptions: Option[] = (rolesData?.data ?? []).map((r) => ({ id: r.id, label: r.name }));
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const emails = emailsText
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter((e) => e.length > 0);
|
||||||
|
onSave({ emails, userIds, roleIds, everyone });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Everyone with inquiry access</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Send to every user whose role grants inquiry visibility.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={everyone} onCheckedChange={setEveryone} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Specific users
|
||||||
|
</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={userOptions}
|
||||||
|
selected={userIds}
|
||||||
|
onChange={setUserIds}
|
||||||
|
placeholder="Add users…"
|
||||||
|
searchPlaceholder="Search users…"
|
||||||
|
emptyText="No users found."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wide text-muted-foreground">Roles</Label>
|
||||||
|
<MultiSelect
|
||||||
|
options={roleOptions}
|
||||||
|
selected={roleIds}
|
||||||
|
onChange={setRoleIds}
|
||||||
|
placeholder="Add roles…"
|
||||||
|
searchPlaceholder="Search roles…"
|
||||||
|
emptyText="No roles found."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Additional email addresses
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
className="text-sm"
|
||||||
|
placeholder="One per line (or comma-separated)"
|
||||||
|
value={emailsText}
|
||||||
|
onChange={(e) => setEmailsText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||||
|
<Save className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||||
|
{saving ? 'Saving…' : 'Save recipients'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { RecipientPicker } from './recipient-picker';
|
||||||
import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency';
|
import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency';
|
||||||
|
|
||||||
interface Setting {
|
interface Setting {
|
||||||
@@ -35,7 +36,7 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: 'boolean' | 'number' | 'json' | 'string' | 'select';
|
type: 'boolean' | 'number' | 'json' | 'string' | 'select' | 'recipients';
|
||||||
defaultValue: unknown;
|
defaultValue: unknown;
|
||||||
options?: Array<{ value: string; label: string }>;
|
options?: Array<{ value: string; label: string }>;
|
||||||
}> = [
|
}> = [
|
||||||
@@ -132,18 +133,18 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'inquiry_notification_recipients',
|
key: 'inquiry_notification_recipients',
|
||||||
label: 'External Notification Recipients',
|
label: 'Berth & contact inquiry alerts',
|
||||||
description:
|
description:
|
||||||
'Additional email addresses that receive sales notifications for new interests (JSON array)',
|
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||||
type: 'json',
|
type: 'recipients',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'residential_notification_recipients',
|
key: 'residential_notification_recipients',
|
||||||
label: 'Residential Notification Recipients',
|
label: 'Residential inquiry alerts',
|
||||||
description:
|
description:
|
||||||
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
|
'Who receives staff alerts for new residential inquiries: users, roles, everyone with inquiry access, and/or emails. Falls back to Inquiry Contact Email when empty.',
|
||||||
type: 'json',
|
type: 'recipients',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -451,6 +452,32 @@ export function SettingsManager() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Notification Recipients (users / roles / everyone / emails) */}
|
||||||
|
{KNOWN_SETTINGS.some((s) => s.type === 'recipients') && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notification Recipients</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Who receives staff alerts for new inquiries. Pick specific users, roles, everyone
|
||||||
|
with inquiry access, and/or extra email addresses.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{KNOWN_SETTINGS.filter((s) => s.type === 'recipients').map((setting) => (
|
||||||
|
<div key={setting.key} className="space-y-2">
|
||||||
|
<Label>{setting.label}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||||
|
<RecipientPicker
|
||||||
|
value={getEffectiveValue(setting.key, setting.defaultValue)}
|
||||||
|
saving={saving === setting.key}
|
||||||
|
onSave={(config) => saveSetting(setting.key, config)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Numeric Settings */}
|
{/* Numeric Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user