import * as React from 'react' import { useMemo, useState } from 'react' import { CaretDownIcon, MagnifyingGlassIcon, UserIcon, XIcon } from '@phosphor-icons/react' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk' import { Popover, PopoverTrigger, PopoverContent, FieldLabel } from '@/ui' import { useAllUsers, type UserListItem } from '@/api/queries' import { cn } from '@/lib/utils' /** * Single-value user picker — cmdk-powered combobox для audit-фильтра * «Пользователь». Заменяет ручной TextInput на typeahead по username/email/UUID. * *

Источник: GET /api/v1/admin/users (cached user_display table). * INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой * список и падает на input fallback (поле остаётся редактируемым). * *

UX: trigger показывает username (или UUID prefix как fallback); * X сбрасывает selection inline. В popover'е — поиск по subname/email/UUID. */ const labelFor = (u: UserListItem): string => u.preferredUsername || u.name || `${u.sub.slice(0, 8)}…` const secondaryFor = (u: UserListItem): string | null => { if (u.email && u.email !== u.preferredUsername) return u.email return null } export type UserPickerProps = { value: string | undefined onChange: (sub: string | undefined) => void label?: React.ReactNode placeholder?: string disabled?: boolean } export function UserPicker({ value, onChange, label, placeholder, disabled }: UserPickerProps) { const [open, setOpen] = useState(false) const usersQ = useAllUsers() const usersBySub = useMemo(() => { const m = new Map() for (const u of usersQ.data ?? []) m.set(u.sub, u) return m }, [usersQ.data]) const selected = value ? usersBySub.get(value) : undefined const triggerLabel = useMemo(() => { if (!value) return placeholder ?? 'Любой' if (selected) return labelFor(selected) // Value есть, но юзера нет в кеше — показываем UUID prefix. return `${value.slice(0, 8)}…` }, [value, selected, placeholder]) // Поиск: substring по preferredUsername / name / email / sub. cmdk // делает client-side фильтрацию по value атрибуту CommandItem'а; // концатенируем все поля в один searchable string. const searchableValue = (u: UserListItem): string => [u.preferredUsername, u.name, u.email, u.sub].filter(Boolean).join(' ') const clear = (e: React.MouseEvent) => { e.stopPropagation() onChange(undefined) } return (

{label && {label}} )}
{usersQ.isLoading ? 'Загрузка…' : 'Пользователи не найдены'} {(usersQ.data ?? []).map((u) => { const sec = secondaryFor(u) return ( { onChange(u.sub) setOpen(false) }} className={cn( 'flex items-center gap-2 px-2.5 py-1.5 rounded cursor-pointer text-body', value === u.sub ? 'bg-accent-bg text-accent' : 'text-ink hover:bg-surface-2 aria-selected:bg-surface-2', )} > {labelFor(u)} {sec && ( {sec} )} ) })}
) }