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:
@@ -83,6 +83,8 @@ i18n
|
|||||||
'audit.filter.to': 'По',
|
'audit.filter.to': 'По',
|
||||||
'audit.filter.reset': 'Сбросить',
|
'audit.filter.reset': 'Сбросить',
|
||||||
'audit.filter.apply': 'Применить',
|
'audit.filter.apply': 'Применить',
|
||||||
|
'audit.filter.active': 'Активные фильтры',
|
||||||
|
'audit.filter.removeChip': 'Убрать фильтр',
|
||||||
'audit.col.time': 'Время',
|
'audit.col.time': 'Время',
|
||||||
'audit.col.action': 'Действие',
|
'audit.col.action': 'Действие',
|
||||||
'audit.col.dictionary': 'Справочник',
|
'audit.col.dictionary': 'Справочник',
|
||||||
@@ -348,6 +350,8 @@ i18n
|
|||||||
'audit.filter.to': 'To',
|
'audit.filter.to': 'To',
|
||||||
'audit.filter.reset': 'Reset',
|
'audit.filter.reset': 'Reset',
|
||||||
'audit.filter.apply': 'Apply',
|
'audit.filter.apply': 'Apply',
|
||||||
|
'audit.filter.active': 'Active filters',
|
||||||
|
'audit.filter.removeChip': 'Remove filter',
|
||||||
'audit.col.time': 'Time',
|
'audit.col.time': 'Time',
|
||||||
'audit.col.action': 'Action',
|
'audit.col.action': 'Action',
|
||||||
'audit.col.dictionary': 'Dictionary',
|
'audit.col.dictionary': 'Dictionary',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -25,16 +25,14 @@ import {
|
|||||||
CaretDownIcon,
|
CaretDownIcon,
|
||||||
CaretRightIcon,
|
CaretRightIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
XIcon,
|
||||||
} from '@phosphor-icons/react'
|
} from '@phosphor-icons/react'
|
||||||
import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
|
import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
|
||||||
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'
|
||||||
|
|
||||||
export const Route = createFileRoute('/audit')({
|
|
||||||
component: AuditPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE']
|
const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE']
|
||||||
|
const ACTION_VALUES: ReadonlySet<AuditAction> = new Set(ACTIONS)
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [
|
const PAGE_SIZE_OPTIONS = [
|
||||||
{ id: '50', label: '50' },
|
{ id: '50', label: '50' },
|
||||||
@@ -42,13 +40,99 @@ const PAGE_SIZE_OPTIONS = [
|
|||||||
{ id: '200', label: '200' },
|
{ 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() {
|
function AuditPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const urlSearch = Route.useSearch()
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
const dictionariesQuery = useDictionaries()
|
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)
|
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 { data, isLoading, error, refetch, isFetching } = useAudit(filters)
|
||||||
|
|
||||||
const dictionaryOptions = useMemo(() => {
|
const dictionaryOptions = useMemo(() => {
|
||||||
@@ -67,16 +151,73 @@ function AuditPage() {
|
|||||||
[t],
|
[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 reset = () => {
|
||||||
const empty: AuditFilters = { page: 0, size: filters.size ?? 50 }
|
setDraft({ page: 0, size: filters.size ?? 50 })
|
||||||
setDraft(empty)
|
navigate({
|
||||||
setFilters(empty)
|
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 totalPages = data?.totalPages ?? 0
|
||||||
const currentPage = data?.number ?? 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -120,6 +261,10 @@ function AuditPage() {
|
|||||||
onReset={reset}
|
onReset={reset}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{activeFilters.length > 0 && (
|
||||||
|
<ActiveFilterChips filters={activeFilters} onRemove={removeFilter} />
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
{String(error)}
|
{String(error)}
|
||||||
@@ -135,15 +280,9 @@ function AuditPage() {
|
|||||||
page={currentPage}
|
page={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
size={filters.size ?? 50}
|
size={filters.size ?? 50}
|
||||||
onPrev={() =>
|
onPrev={() => setPage(Math.max(0, currentPage - 1))}
|
||||||
setFilters((f) => ({ ...f, page: Math.max(0, (f.page ?? 0) - 1) }))
|
onNext={() => setPage(currentPage + 1)}
|
||||||
}
|
onSizeChange={setSize}
|
||||||
onNext={() =>
|
|
||||||
setFilters((f) => ({ ...f, page: (f.page ?? 0) + 1 }))
|
|
||||||
}
|
|
||||||
onSizeChange={(size) =>
|
|
||||||
setFilters((f) => ({ ...f, page: 0, size }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -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 = {
|
type FilterPanelProps = {
|
||||||
filters: AuditFilters
|
filters: AuditFilters
|
||||||
onChange: (f: AuditFilters) => void
|
onChange: (f: AuditFilters) => void
|
||||||
@@ -175,6 +347,14 @@ function FilterPanel({
|
|||||||
const fromDate = parseFormDate(filters.from)
|
const fromDate = parseFormDate(filters.from)
|
||||||
const toDate = parseFormDate(filters.to)
|
const toDate = parseFormDate(filters.to)
|
||||||
|
|
||||||
|
// Apply на Enter в TextInput'ах — быстрее чем клик мышкой
|
||||||
|
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
onApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-regolith rounded-lg p-4 space-y-3">
|
<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">
|
<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')}
|
label={t('audit.filter.user')}
|
||||||
value={filters.userId ?? ''}
|
value={filters.userId ?? ''}
|
||||||
onChange={(e) => set('userId', e.target.value || undefined)}
|
onChange={(e) => set('userId', e.target.value || undefined)}
|
||||||
|
onKeyDown={handleEnter}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('audit.filter.businessKey')}
|
label={t('audit.filter.businessKey')}
|
||||||
value={filters.businessKey ?? ''}
|
value={filters.businessKey ?? ''}
|
||||||
onChange={(e) => set('businessKey', e.target.value || undefined)}
|
onChange={(e) => set('businessKey', e.target.value || undefined)}
|
||||||
|
onKeyDown={handleEnter}
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
label={t('audit.filter.from')}
|
label={t('audit.filter.from')}
|
||||||
|
|||||||
Reference in New Issue
Block a user