feat(audit): URL persistence фильтров + chip-list активных

Фильтры audit log теперь живут в URL search params: dict, action, user,
bk, from, to, page, size. Result:
- reload не сбрасывает фильтр
- админ может поделиться ссылкой "audit за вчера на ground_station"
- browser back/forward работает по фильтрам как навигация
- ссылка из логов / тикета сразу открывает нужный срез

Chip-list над таблицей показывает активные фильтры; клик на chip
убирает только этот фильтр (не сбрасывает остальные). UX выгрузка
без копания в свёрнутой панели.

Apply на Enter для TextInput'ов user/businessKey — быстрее чем кнопка.

Validation в validateSearch: action — enum, page — non-negative int,
size — whitelist (50/100/200). Невалидные query params silently
дропаются (не throw — admin-ui остаётся на странице).
This commit is contained in:
Zimin A.N.
2026-05-07 11:03:43 +03:00
parent ac93151fe6
commit 92507ab0a1
2 changed files with 206 additions and 20 deletions
+4
View File
@@ -83,6 +83,8 @@ i18n
'audit.filter.to': 'По',
'audit.filter.reset': 'Сбросить',
'audit.filter.apply': 'Применить',
'audit.filter.active': 'Активные фильтры',
'audit.filter.removeChip': 'Убрать фильтр',
'audit.col.time': 'Время',
'audit.col.action': 'Действие',
'audit.col.dictionary': 'Справочник',
@@ -348,6 +350,8 @@ i18n
'audit.filter.to': 'To',
'audit.filter.reset': 'Reset',
'audit.filter.apply': 'Apply',
'audit.filter.active': 'Active filters',
'audit.filter.removeChip': 'Remove filter',
'audit.col.time': 'Time',
'audit.col.action': 'Action',
'audit.col.dictionary': 'Dictionary',
+202 -20
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
@@ -25,16 +25,14 @@ import {
CaretDownIcon,
CaretRightIcon,
DownloadIcon,
XIcon,
} from '@phosphor-icons/react'
import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
import { localTzOffset, parseFormDate } from '@/lib/dates'
export const Route = createFileRoute('/audit')({
component: AuditPage,
})
const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE']
const ACTION_VALUES: ReadonlySet<AuditAction> = new Set(ACTIONS)
const PAGE_SIZE_OPTIONS = [
{ id: '50', label: '50' },
@@ -42,13 +40,99 @@ const PAGE_SIZE_OPTIONS = [
{ id: '200', label: '200' },
]
const PAGE_SIZE_VALUES = new Set([50, 100, 200])
/**
* URL search params для /audit. Сериализуем фильтры чтобы:
* - reload страницы не сбрасывал фильтр
* - админ мог поделиться ссылкой "audit за вчера на ground_station"
* - browser back/forward работал по фильтрам как навигация
*
* Все поля optional. Validate'им: action — enum, page/size — number.
* Остальное — строка как есть (длина не лимитируем — backend всё равно режет).
*/
type AuditSearch = {
dict?: string
action?: AuditAction
user?: string
bk?: string
from?: string
to?: string
page?: number
size?: number
}
const validateSearch = (raw: Record<string, unknown>): AuditSearch => {
const out: AuditSearch = {}
if (typeof raw.dict === 'string' && raw.dict.length > 0) out.dict = raw.dict
if (typeof raw.action === 'string' && ACTION_VALUES.has(raw.action as AuditAction)) {
out.action = raw.action as AuditAction
}
if (typeof raw.user === 'string' && raw.user.length > 0) out.user = raw.user
if (typeof raw.bk === 'string' && raw.bk.length > 0) out.bk = raw.bk
if (typeof raw.from === 'string' && raw.from.length > 0) out.from = raw.from
if (typeof raw.to === 'string' && raw.to.length > 0) out.to = raw.to
if (typeof raw.page === 'number' && Number.isInteger(raw.page) && raw.page >= 0) {
out.page = raw.page
} else if (typeof raw.page === 'string') {
const p = Number.parseInt(raw.page, 10)
if (Number.isInteger(p) && p >= 0) out.page = p
}
if (typeof raw.size === 'number' && PAGE_SIZE_VALUES.has(raw.size)) out.size = raw.size
else if (typeof raw.size === 'string') {
const s = Number.parseInt(raw.size, 10)
if (PAGE_SIZE_VALUES.has(s)) out.size = s
}
return out
}
export const Route = createFileRoute('/audit')({
component: AuditPage,
validateSearch,
})
/** URL search → AuditFilters (API contract). */
const searchToFilters = (s: AuditSearch): AuditFilters => ({
dictionaryName: s.dict,
action: s.action ?? '',
userId: s.user,
businessKey: s.bk,
from: s.from,
to: s.to,
page: s.page ?? 0,
size: s.size ?? 50,
})
/** AuditFilters → URL search (для navigate({search})). undefined дропаем. */
const filtersToSearch = (f: AuditFilters): AuditSearch => {
const out: AuditSearch = {}
if (f.dictionaryName) out.dict = f.dictionaryName
if (f.action) out.action = f.action as AuditAction
if (f.userId) out.user = f.userId
if (f.businessKey) out.bk = f.businessKey
if (f.from) out.from = f.from
if (f.to) out.to = f.to
if (f.page && f.page > 0) out.page = f.page
if (f.size && f.size !== 50) out.size = f.size
return out
}
function AuditPage() {
const { t } = useTranslation()
const urlSearch = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const dictionariesQuery = useDictionaries()
const [filters, setFilters] = useState<AuditFilters>({ page: 0, size: 50 })
// filters: derived из URL — single source of truth.
// draft: локальный staging фильтра до Apply — не идёт в URL до явного push.
const filters = useMemo(() => searchToFilters(urlSearch), [urlSearch])
const [draft, setDraft] = useState<AuditFilters>(filters)
// Sync draft когда URL поменялся снаружи (browser back/forward, ссылка).
useEffect(() => {
setDraft(filters)
}, [filters])
const { data, isLoading, error, refetch, isFetching } = useAudit(filters)
const dictionaryOptions = useMemo(() => {
@@ -67,16 +151,73 @@ function AuditPage() {
[t],
)
const apply = () => setFilters({ ...draft, page: 0 })
// navigate с replace=false — каждый Apply пишет history entry.
// Reset → пустой URL без params.
const apply = () =>
navigate({ search: filtersToSearch({ ...draft, page: 0 }), replace: false })
const reset = () => {
const empty: AuditFilters = { page: 0, size: filters.size ?? 50 }
setDraft(empty)
setFilters(empty)
setDraft({ page: 0, size: filters.size ?? 50 })
navigate({
search: filters.size && filters.size !== 50 ? { size: filters.size } : {},
replace: false,
})
}
const setPage = (page: number) =>
navigate({ search: filtersToSearch({ ...filters, page }), replace: false })
const setSize = (size: number) =>
navigate({ search: filtersToSearch({ ...filters, page: 0, size }), replace: false })
const totalPages = data?.totalPages ?? 0
const currentPage = data?.number ?? 0
// Активные фильтры для chip-list. Action='' → нет фильтра.
// page/size исключаем — это пагинация, не фильтр.
const activeFilters = useMemo(() => {
const chips: { key: keyof AuditFilters; label: string; value: string }[] = []
if (filters.dictionaryName) {
const dict = dictionariesQuery.data?.find((d) => d.name === filters.dictionaryName)
chips.push({
key: 'dictionaryName',
label: t('audit.filter.dictionary'),
value: dict?.displayName ?? filters.dictionaryName,
})
}
if (filters.action) {
chips.push({
key: 'action',
label: t('audit.filter.action'),
value: t(`audit.action.${filters.action}`),
})
}
if (filters.userId) {
chips.push({ key: 'userId', label: t('audit.filter.user'), value: filters.userId })
}
if (filters.businessKey) {
chips.push({
key: 'businessKey',
label: t('audit.filter.businessKey'),
value: filters.businessKey,
})
}
if (filters.from) {
chips.push({ key: 'from', label: t('audit.filter.from'), value: filters.from.slice(0, 10) })
}
if (filters.to) {
chips.push({ key: 'to', label: t('audit.filter.to'), value: filters.to.slice(0, 10) })
}
return chips
}, [filters, dictionariesQuery.data, t])
const removeFilter = (key: keyof AuditFilters) => {
const next: AuditFilters = { ...filters, page: 0 }
if (key === 'action') next.action = ''
else next[key] = undefined
navigate({ search: filtersToSearch(next), replace: false })
}
return (
<div className="space-y-6">
<PageHeader
@@ -120,6 +261,10 @@ function AuditPage() {
onReset={reset}
/>
{activeFilters.length > 0 && (
<ActiveFilterChips filters={activeFilters} onRemove={removeFilter} />
)}
{error ? (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
@@ -135,15 +280,9 @@ function AuditPage() {
page={currentPage}
totalPages={totalPages}
size={filters.size ?? 50}
onPrev={() =>
setFilters((f) => ({ ...f, page: Math.max(0, (f.page ?? 0) - 1) }))
}
onNext={() =>
setFilters((f) => ({ ...f, page: (f.page ?? 0) + 1 }))
}
onSizeChange={(size) =>
setFilters((f) => ({ ...f, page: 0, size }))
}
onPrev={() => setPage(Math.max(0, currentPage - 1))}
onNext={() => setPage(currentPage + 1)}
onSizeChange={setSize}
/>
</>
)}
@@ -151,6 +290,39 @@ function AuditPage() {
)
}
type ActiveFilterChipsProps = {
filters: { key: keyof AuditFilters; label: string; value: string }[]
onRemove: (key: keyof AuditFilters) => void
}
function ActiveFilterChips({ filters, onRemove }: ActiveFilterChipsProps) {
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-2xs uppercase tracking-label text-carbon/70">
{t('audit.filter.active')}
</span>
{filters.map((f) => (
<button
key={f.key}
type="button"
onClick={() => onRemove(f.key)}
className={[
'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm border text-2xs',
'bg-white border-regolith hover:border-carbon/40 hover:bg-regolith/30',
'transition-colors group',
].join(' ')}
title={t('audit.filter.removeChip')}
>
<span className="text-carbon/70">{f.label}:</span>
<span className="font-medium text-black">{f.value}</span>
<XIcon size={12} className="text-carbon/50 group-hover:text-mars" />
</button>
))}
</div>
)
}
type FilterPanelProps = {
filters: AuditFilters
onChange: (f: AuditFilters) => void
@@ -175,6 +347,14 @@ function FilterPanel({
const fromDate = parseFormDate(filters.from)
const toDate = parseFormDate(filters.to)
// Apply на Enter в TextInput'ах — быстрее чем клик мышкой
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onApply()
}
}
return (
<div className="bg-white border border-regolith rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -194,11 +374,13 @@ function FilterPanel({
label={t('audit.filter.user')}
value={filters.userId ?? ''}
onChange={(e) => set('userId', e.target.value || undefined)}
onKeyDown={handleEnter}
/>
<TextInput
label={t('audit.filter.businessKey')}
value={filters.businessKey ?? ''}
onChange={(e) => set('businessKey', e.target.value || undefined)}
onKeyDown={handleEnter}
/>
<DatePicker
label={t('audit.filter.from')}