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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user