Merge branch 'feat/audit-period-presets' into 'main'
feat: /audit — кастомные фильтры (период presets + user typeahead) See merge request 2-6/2-6-4/terravault/ordinis!263
This commit is contained in:
@@ -782,6 +782,39 @@ export const useLiveRecord = (dict: string | undefined, businessKey: string | un
|
|||||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string | undefined) =>
|
export const useDictionaryDetail = (name: string | undefined) =>
|
||||||
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
|
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список всех закэшированных юзеров — для пикеров «Пользователь» в
|
||||||
|
* фильтрах audit/reviews. INTERNAL+ scope; non-INTERNAL получит 403,
|
||||||
|
* fallback на пустой массив (retry: false).
|
||||||
|
*/
|
||||||
|
export type UserListItem = {
|
||||||
|
sub: string
|
||||||
|
preferredUsername?: string | null
|
||||||
|
name?: string | null
|
||||||
|
email?: string | null
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const allUsersQuery = queryOptions({
|
||||||
|
queryKey: ['users', 'all'] as const,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
queryFn: async (): Promise<UserListItem[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<UserListItem[]>('/admin/users', {
|
||||||
|
params: { limit: 500 },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const status = (e as { response?: { status?: number } })?.response?.status
|
||||||
|
if (status === 403 || status === 404) return []
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useAllUsers = () => useQuery(allUsersQuery)
|
||||||
export const useRecords = (
|
export const useRecords = (
|
||||||
dictionaryName: string,
|
dictionaryName: string,
|
||||||
scopeCsv: string,
|
scopeCsv: string,
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { CalendarBlankIcon, XIcon, CaretDownIcon } from '@phosphor-icons/react'
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
import type { DateRange } from 'react-day-picker'
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent, FieldLabel } from '@/ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-специфичный period filter. Заменяет голый DateRangePicker на
|
||||||
|
* preset-driven popover: чипы быстрых периодов (Сегодня, 7 дней, Этот
|
||||||
|
* месяц…) + произвольный диапазон через DayPicker. Pattern взят из
|
||||||
|
* Datadog/Grafana time picker'ов — для audit log'а это типичный UX.
|
||||||
|
*
|
||||||
|
* <p>Compact trigger (h-9) — помещается в строку фильтров на /audit.
|
||||||
|
* Открытое состояние: presets-список слева + календарь справа (или
|
||||||
|
* только presets если custom не выбран; календарь раскрывается клик'ом
|
||||||
|
* на «Произвольный диапазон»).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Range = { from?: Date; to?: Date }
|
||||||
|
|
||||||
|
type Preset = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
range: () => Range
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazy DayPicker — ~80kb gz, не пакуем в initial bundle audit'а. */
|
||||||
|
const DayPickerLazy = React.lazy(async () => {
|
||||||
|
const [{ DayPicker }] = await Promise.all([
|
||||||
|
import('react-day-picker'),
|
||||||
|
import('react-day-picker/style.css'),
|
||||||
|
])
|
||||||
|
return { default: DayPicker }
|
||||||
|
})
|
||||||
|
|
||||||
|
const startOfDay = (d: Date): Date => {
|
||||||
|
const n = new Date(d)
|
||||||
|
n.setHours(0, 0, 0, 0)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
const endOfDay = (d: Date): Date => {
|
||||||
|
const n = new Date(d)
|
||||||
|
n.setHours(23, 59, 59, 999)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
const addDays = (d: Date, n: number): Date => {
|
||||||
|
const r = new Date(d)
|
||||||
|
r.setDate(r.getDate() + n)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESETS: Preset[] = [
|
||||||
|
{
|
||||||
|
id: 'today',
|
||||||
|
label: 'Сегодня',
|
||||||
|
range: () => ({ from: startOfDay(new Date()), to: endOfDay(new Date()) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yesterday',
|
||||||
|
label: 'Вчера',
|
||||||
|
range: () => {
|
||||||
|
const y = addDays(new Date(), -1)
|
||||||
|
return { from: startOfDay(y), to: endOfDay(y) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7d',
|
||||||
|
label: 'Последние 7 дней',
|
||||||
|
range: () => ({ from: startOfDay(addDays(new Date(), -6)), to: endOfDay(new Date()) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '30d',
|
||||||
|
label: 'Последние 30 дней',
|
||||||
|
range: () => ({ from: startOfDay(addDays(new Date(), -29)), to: endOfDay(new Date()) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'thisMonth',
|
||||||
|
label: 'Этот месяц',
|
||||||
|
range: () => {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
from: startOfDay(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||||
|
to: endOfDay(new Date()),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lastMonth',
|
||||||
|
label: 'Прошлый месяц',
|
||||||
|
range: () => {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
from: startOfDay(new Date(now.getFullYear(), now.getMonth() - 1, 1)),
|
||||||
|
to: endOfDay(new Date(now.getFullYear(), now.getMonth(), 0)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Сравнивает два range'а по дате (без HH:MM:SS) — нам нужно знать что
|
||||||
|
* current value соответствует preset'у с точностью до дня, не до миллисекунды. */
|
||||||
|
const sameRange = (a: Range, b: Range): boolean => {
|
||||||
|
const ds = (d: Date | undefined) =>
|
||||||
|
d ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : undefined
|
||||||
|
return ds(a.from) === ds(b.from) && ds(a.to) === ds(b.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (d: Date): string =>
|
||||||
|
`${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`
|
||||||
|
|
||||||
|
const formatRange = (r: Range): string => {
|
||||||
|
if (!r.from && !r.to) return 'Все время'
|
||||||
|
if (r.from && r.to) return `${formatDate(r.from)} — ${formatDate(r.to)}`
|
||||||
|
if (r.from) return `с ${formatDate(r.from)}`
|
||||||
|
if (r.to) return `до ${formatDate(r.to)}`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeriodFilterProps = {
|
||||||
|
value: Range
|
||||||
|
onChange: (range: Range) => void
|
||||||
|
label?: React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodFilter({ value, onChange, label, disabled }: PeriodFilterProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
// Custom раскрывается либо если active preset не находится, либо если
|
||||||
|
// юзер кликнул «Произвольный диапазон» — сохраняем state между
|
||||||
|
// открытиями popover'а для intuitive поведения.
|
||||||
|
const [showCalendar, setShowCalendar] = useState(false)
|
||||||
|
|
||||||
|
const activePreset = useMemo(
|
||||||
|
() => PRESETS.find((p) => sameRange(p.range(), value))?.id,
|
||||||
|
[value],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Триггер показывает либо preset label, либо range, либо «Все время».
|
||||||
|
const triggerLabel = useMemo(() => {
|
||||||
|
if (activePreset) {
|
||||||
|
return PRESETS.find((p) => p.id === activePreset)!.label
|
||||||
|
}
|
||||||
|
return formatRange(value)
|
||||||
|
}, [activePreset, value])
|
||||||
|
|
||||||
|
const hasValue = Boolean(value.from || value.to)
|
||||||
|
|
||||||
|
const dpRange: DateRange | undefined = useMemo(
|
||||||
|
() => (value.from || value.to ? { from: value.from, to: value.to } : undefined),
|
||||||
|
[value.from, value.to],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelect = (next: DateRange | undefined) => {
|
||||||
|
if (!next) {
|
||||||
|
onChange({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// DayPicker отдаёт даты с time=00:00; нормализуем from в start-of-day
|
||||||
|
// и to в end-of-day чтобы matched диапазон покрывал полные сутки.
|
||||||
|
onChange({
|
||||||
|
from: next.from ? startOfDay(next.from) : undefined,
|
||||||
|
to: next.to ? endOfDay(next.to) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreset = (p: Preset) => {
|
||||||
|
onChange(p.range())
|
||||||
|
setShowCalendar(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange({})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-2 h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
|
||||||
|
'hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<CalendarBlankIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||||
|
<span className={cn('truncate', !hasValue && 'text-mute')}>
|
||||||
|
{hasValue ? triggerLabel : 'Все время'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
|
{hasValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Сбросить период"
|
||||||
|
onClick={clear}
|
||||||
|
className="p-0.5 rounded hover:bg-line/40 text-mute hover:text-ink"
|
||||||
|
>
|
||||||
|
<XIcon size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<CaretDownIcon size={12} weight="regular" className="text-mute" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="p-0 w-auto max-w-[min(95vw,720px)]"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Close className="hidden" />
|
||||||
|
<div className="flex">
|
||||||
|
{/* Presets list */}
|
||||||
|
<div className="flex flex-col p-1 border-r border-line min-w-[160px]">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(p)}
|
||||||
|
className={cn(
|
||||||
|
'text-left px-2.5 py-1.5 rounded text-body transition-colors',
|
||||||
|
activePreset === p.id
|
||||||
|
? 'bg-accent-bg text-accent font-medium'
|
||||||
|
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCalendar((s) => !s)}
|
||||||
|
className={cn(
|
||||||
|
'text-left px-2.5 py-1.5 rounded text-body transition-colors mt-1 border-t border-line',
|
||||||
|
showCalendar || (!activePreset && hasValue)
|
||||||
|
? 'bg-accent-bg text-accent font-medium'
|
||||||
|
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Произвольный диапазон
|
||||||
|
<CaretDownIcon
|
||||||
|
size={12}
|
||||||
|
weight="regular"
|
||||||
|
className={cn(
|
||||||
|
'inline-block ml-1 transition-transform',
|
||||||
|
showCalendar || (!activePreset && hasValue) ? 'rotate-180' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Calendar (custom range mode) */}
|
||||||
|
{(showCalendar || (!activePreset && hasValue)) && (
|
||||||
|
<div className="p-2">
|
||||||
|
<React.Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-64 w-[520px] flex items-center justify-center text-mute text-cap">
|
||||||
|
Загрузка календаря…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DayPickerLazy
|
||||||
|
mode="range"
|
||||||
|
selected={dpRange}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
numberOfMonths={2}
|
||||||
|
showOutsideDays
|
||||||
|
weekStartsOn={1}
|
||||||
|
className="ord-day-picker"
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>Источник: GET /api/v1/admin/users (cached user_display table).
|
||||||
|
* INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой
|
||||||
|
* список и падает на input fallback (поле остаётся редактируемым).
|
||||||
|
*
|
||||||
|
* <p>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<string, UserListItem>()
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{label && <FieldLabel>{label}</FieldLabel>}
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-2 h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
|
||||||
|
'hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<UserIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||||
|
<span className={cn('truncate text-left', !value && 'text-mute')}>
|
||||||
|
{triggerLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Сбросить пользователя"
|
||||||
|
onClick={clear}
|
||||||
|
className="p-0.5 rounded hover:bg-line/40 text-mute hover:text-ink"
|
||||||
|
>
|
||||||
|
<XIcon size={12} weight="bold" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<CaretDownIcon size={12} weight="regular" className="text-mute" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="p-0 w-[min(95vw,360px)]"
|
||||||
|
>
|
||||||
|
<Command className="bg-surface rounded-md">
|
||||||
|
<div className="flex items-center gap-2 px-2 border-b border-line">
|
||||||
|
<MagnifyingGlassIcon size={14} weight="regular" className="text-mute shrink-0" />
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Поиск по имени, email или UUID…"
|
||||||
|
className="h-9 w-full bg-transparent outline-none text-body text-ink placeholder:text-mute"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CommandList className="max-h-72 overflow-y-auto py-1">
|
||||||
|
<CommandEmpty className="px-3 py-2 text-cell text-mute">
|
||||||
|
{usersQ.isLoading ? 'Загрузка…' : 'Пользователи не найдены'}
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{(usersQ.data ?? []).map((u) => {
|
||||||
|
const sec = secondaryFor(u)
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={u.sub}
|
||||||
|
value={searchableValue(u)}
|
||||||
|
onSelect={() => {
|
||||||
|
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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block truncate">{labelFor(u)}</span>
|
||||||
|
{sec && (
|
||||||
|
<span className="block truncate text-cap text-mute">{sec}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
DateRangePicker,
|
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
@@ -31,6 +30,8 @@ import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
|
|||||||
import { UserCell } from '@/lib/useUserDisplay'
|
import { UserCell } from '@/lib/useUserDisplay'
|
||||||
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
|
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
|
||||||
import { localTzOffset, parseFormDate } from '@/lib/dates'
|
import { localTzOffset, parseFormDate } from '@/lib/dates'
|
||||||
|
import { PeriodFilter } from '@/components/audit/PeriodFilter'
|
||||||
|
import { UserPicker } from '@/components/audit/UserPicker'
|
||||||
|
|
||||||
// Все 12 audit action types — должны matchить backend (AuditLogger,
|
// Все 12 audit action types — должны matchить backend (AuditLogger,
|
||||||
// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
|
// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
|
||||||
@@ -390,11 +391,14 @@ function FilterPanel({
|
|||||||
value={filters.action ?? ''}
|
value={filters.action ?? ''}
|
||||||
onChange={(id) => set('action', (id as AuditAction) || '')}
|
onChange={(id) => set('action', (id as AuditAction) || '')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
{/* UserPicker — cmdk-typeahead из user-cache (/admin/users). Раньше
|
||||||
|
был TextInput где юзер вручную копипастил UUID; теперь поиск по
|
||||||
|
preferredUsername/email/UUID. INTERNAL+ scope, non-INTERNAL получает
|
||||||
|
пустой список (fallback в UserPicker: показывает UUID-prefix). */}
|
||||||
|
<UserPicker
|
||||||
label={t('audit.filter.user')}
|
label={t('audit.filter.user')}
|
||||||
value={filters.userId ?? ''}
|
value={filters.userId}
|
||||||
onChange={(e) => set('userId', e.target.value || undefined)}
|
onChange={(sub) => set('userId', sub)}
|
||||||
onKeyDown={handleEnter}
|
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('audit.filter.businessKey')}
|
label={t('audit.filter.businessKey')}
|
||||||
@@ -402,10 +406,10 @@ function FilterPanel({
|
|||||||
onChange={(e) => set('businessKey', e.target.value || undefined)}
|
onChange={(e) => set('businessKey', e.target.value || undefined)}
|
||||||
onKeyDown={handleEnter}
|
onKeyDown={handleEnter}
|
||||||
/>
|
/>
|
||||||
{/* DateRangePicker — один popover с двумя месяцами, выбор start/end
|
{/* PeriodFilter — preset chips («Сегодня», «7 дней», «Этот месяц»…)
|
||||||
в одном UI. ISO строки с local TZ offset формируются здесь чтобы
|
+ произвольный диапазон через DayPicker. ISO с local TZ offset
|
||||||
backend получал правильный bounds (T00:00:00 для from, T23:59:59 для to). */}
|
формируется здесь (T00:00:00 для from, T23:59:59 для to). */}
|
||||||
<DateRangePicker
|
<PeriodFilter
|
||||||
label={t('audit.filter.range', { defaultValue: 'Период' })}
|
label={t('audit.filter.range', { defaultValue: 'Период' })}
|
||||||
value={{ from: fromDate ?? undefined, to: toDate ?? undefined }}
|
value={{ from: fromDate ?? undefined, to: toDate ?? undefined }}
|
||||||
onChange={({ from, to }) => {
|
onChange={({ from, to }) => {
|
||||||
|
|||||||
+19
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@@ -125,6 +126,24 @@ public class UserDisplayService {
|
|||||||
return hotCache.size();
|
return hotCache.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список всех закэшированных юзеров — для admin-UI пикеров (audit
|
||||||
|
* filter «Пользователь», review reviewer selector и т.п.). Сортируется
|
||||||
|
* по preferred_username для предсказуемой выдачи; hard-capped лимит
|
||||||
|
* чтобы не вытащить всю таблицу когда cache разрастётся до 10k+
|
||||||
|
* (в этот момент перейдём на server-side search; пока 1000 хватит).
|
||||||
|
*/
|
||||||
|
public List<UserDisplayInfo> listAll(int limit) {
|
||||||
|
int cap = Math.min(Math.max(limit, 1), 1000);
|
||||||
|
return repository.findAll().stream()
|
||||||
|
.map(UserDisplayService::toInfo)
|
||||||
|
.sorted(java.util.Comparator.comparing(
|
||||||
|
(UserDisplayInfo i) -> i.preferredUsername() == null ? "" : i.preferredUsername(),
|
||||||
|
String.CASE_INSENSITIVE_ORDER))
|
||||||
|
.limit(cap)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// --- internals ---
|
// --- internals ---
|
||||||
|
|
||||||
private UserDisplayInfo upsert(String sub, String preferredUsername, String name,
|
private UserDisplayInfo upsert(String sub, String preferredUsername, String name,
|
||||||
|
|||||||
+16
@@ -7,9 +7,11 @@ import cloud.nstart.terravault.ordinis.restapi.service.UserDisplayService;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve UUID (JWT sub) → human-readable display info. Frontend UserCell
|
* Resolve UUID (JWT sub) → human-readable display info. Frontend UserCell
|
||||||
@@ -57,6 +59,20 @@ public class UserDisplayController {
|
|||||||
"authenticated request на этом backend'е чтобы попасть в кэш."));
|
"authenticated request на этом backend'е чтобы попасть в кэш."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список всех закэшированных юзеров — для admin-UI пикеров (audit
|
||||||
|
* filter «Пользователь», reviewer selectors и т.п.). INTERNAL+ scope,
|
||||||
|
* hard-cap limit 1000.
|
||||||
|
*/
|
||||||
|
@GetMapping("")
|
||||||
|
public List<UserDisplayResponse> list(
|
||||||
|
@RequestParam(defaultValue = "500") int limit) {
|
||||||
|
requireInternal();
|
||||||
|
return service.listAll(limit).stream()
|
||||||
|
.map(UserDisplayResponse::from)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public record UserDisplayResponse(
|
public record UserDisplayResponse(
|
||||||
String sub,
|
String sub,
|
||||||
String preferredUsername,
|
String preferredUsername,
|
||||||
|
|||||||
Reference in New Issue
Block a user