diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 0365fef..236ea34 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -782,6 +782,39 @@ export const useLiveRecord = (dict: string | undefined, businessKey: string | un export const useDictionaries = () => useQuery(dictionariesQuery) export const useDictionaryDetail = (name: string | undefined) => 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 => { + try { + const { data } = await apiClient.get('/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 = ( dictionaryName: string, scopeCsv: string, diff --git a/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx b/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx new file mode 100644 index 0000000..e8a8a04 --- /dev/null +++ b/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx @@ -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. + * + *

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 ( +

+ {label && {label}} + + + + )} + + + + + + +
+ {/* Presets list */} +
+ {PRESETS.map((p) => ( + + ))} + +
+ {/* Calendar (custom range mode) */} + {(showCalendar || (!activePreset && hasValue)) && ( +
+ + Загрузка календаря… +
+ } + > + + +
+ )} +
+ + + + ) +} diff --git a/ordinis-admin-ui/src/components/audit/UserPicker.tsx b/ordinis-admin-ui/src/components/audit/UserPicker.tsx new file mode 100644 index 0000000..f0da2f9 --- /dev/null +++ b/ordinis-admin-ui/src/components/audit/UserPicker.tsx @@ -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. + * + *

Источник: 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} + )} + + + ) + })} + + +
+
+
+
+ ) +} diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx index 3ade8a2..c406a28 100644 --- a/ordinis-admin-ui/src/routes/audit.tsx +++ b/ordinis-admin-ui/src/routes/audit.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next' import { Badge, Button, - DateRangePicker, EmptyState, IconButton, LoadingBlock, @@ -31,6 +30,8 @@ import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries' import { UserCell } from '@/lib/useUserDisplay' import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client' 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, // SchemaDraftService.applyDecision, DraftService.emitDraftEvent). @@ -390,11 +391,14 @@ function FilterPanel({ value={filters.action ?? ''} onChange={(id) => set('action', (id as AuditAction) || '')} /> - set('userId', e.target.value || undefined)} - onKeyDown={handleEnter} + value={filters.userId} + onChange={(sub) => set('userId', sub)} /> set('businessKey', e.target.value || undefined)} onKeyDown={handleEnter} /> - {/* DateRangePicker — один popover с двумя месяцами, выбор start/end - в одном UI. ISO строки с local TZ offset формируются здесь чтобы - backend получал правильный bounds (T00:00:00 для from, T23:59:59 для to). */} - { diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java index 8108ad1..38ba10d 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.OffsetDateTime; +import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -125,6 +126,24 @@ public class UserDisplayService { return hotCache.size(); } + /** + * Список всех закэшированных юзеров — для admin-UI пикеров (audit + * filter «Пользователь», review reviewer selector и т.п.). Сортируется + * по preferred_username для предсказуемой выдачи; hard-capped лимит + * чтобы не вытащить всю таблицу когда cache разрастётся до 10k+ + * (в этот момент перейдём на server-side search; пока 1000 хватит). + */ + public List 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 --- private UserDisplayInfo upsert(String sub, String preferredUsername, String name, diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java index 1252ffc..17ed108 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java @@ -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.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.OffsetDateTime; +import java.util.List; /** * Resolve UUID (JWT sub) → human-readable display info. Frontend UserCell @@ -57,6 +59,20 @@ public class UserDisplayController { "authenticated request на этом backend'е чтобы попасть в кэш.")); } + /** + * Список всех закэшированных юзеров — для admin-UI пикеров (audit + * filter «Пользователь», reviewer selectors и т.п.). INTERNAL+ scope, + * hard-cap limit 1000. + */ + @GetMapping("") + public List list( + @RequestParam(defaultValue = "500") int limit) { + requireInternal(); + return service.listAll(limit).stream() + .map(UserDisplayResponse::from) + .toList(); + } + public record UserDisplayResponse( String sub, String preferredUsername,