diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index fbc1593..24b5fa9 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx index ca0405e..ebdf6da 100644 --- a/ordinis-admin-ui/src/routes/audit.tsx +++ b/ordinis-admin-ui/src/routes/audit.tsx @@ -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 = 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): 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({ 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(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 (
+ {activeFilters.length > 0 && ( + + )} + {error ? ( {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 ( +
+ + {t('audit.filter.active')} + + {filters.map((f) => ( + + ))} +
+ ) +} + 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) => { + if (e.key === 'Enter') { + e.preventDefault() + onApply() + } + } + return (
@@ -194,11 +374,13 @@ function FilterPanel({ label={t('audit.filter.user')} value={filters.userId ?? ''} onChange={(e) => set('userId', e.target.value || undefined)} + onKeyDown={handleEnter} /> set('businessKey', e.target.value || undefined)} + onKeyDown={handleEnter} />