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';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { RecipientPicker } from './recipient-picker';
|
||||
import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency';
|
||||
|
||||
interface Setting {
|
||||
@@ -35,7 +36,7 @@ const KNOWN_SETTINGS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'boolean' | 'number' | 'json' | 'string' | 'select';
|
||||
type: 'boolean' | 'number' | 'json' | 'string' | 'select' | 'recipients';
|
||||
defaultValue: unknown;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}> = [
|
||||
@@ -132,18 +133,18 @@ const KNOWN_SETTINGS: Array<{
|
||||
},
|
||||
{
|
||||
key: 'inquiry_notification_recipients',
|
||||
label: 'External Notification Recipients',
|
||||
label: 'Berth & contact inquiry alerts',
|
||||
description:
|
||||
'Additional email addresses that receive sales notifications for new interests (JSON array)',
|
||||
type: 'json',
|
||||
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
|
||||
type: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'residential_notification_recipients',
|
||||
label: 'Residential Notification Recipients',
|
||||
label: 'Residential inquiry alerts',
|
||||
description:
|
||||
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
|
||||
type: 'json',
|
||||
'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: 'recipients',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
@@ -451,6 +452,32 @@ export function SettingsManager() {
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user