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:
2026-06-02 17:36:24 +02:00
parent 5ea0c75fff
commit 44b004fa8f
2 changed files with 276 additions and 7 deletions

View 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>
);
}

View File

@@ -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>