From 44b004fa8ff729a0b8ccbcc0b58a2e5f1b7b40cb Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 17:36:24 +0200 Subject: [PATCH] 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) --- .../admin/settings/recipient-picker.tsx | 242 ++++++++++++++++++ .../admin/settings/settings-manager.tsx | 41 ++- 2 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 src/components/admin/settings/recipient-picker.tsx diff --git a/src/components/admin/settings/recipient-picker.tsx b/src/components/admin/settings/recipient-picker.tsx new file mode 100644 index 00000000..b880b17b --- /dev/null +++ b/src/components/admin/settings/recipient-picker.tsx @@ -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; + 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 ( +
+ {selected.length > 0 ? ( +
+ {selected.map((id) => ( + + {byId.get(id) ?? id.slice(0, 8)} + + + ))} +
+ ) : null} + + + + + + + + + {emptyText} + + {options.map((o) => ( + toggle(o.id)}> + + {o.label} + + ))} + + + + + +
+ ); +} + +/** + * 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(initial.userIds); + const [roleIds, setRoleIds] = useState(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 ( +
+
+
+ +

+ Send to every user whose role grants inquiry visibility. +

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ +