import { useEffect, useMemo, useState } from 'react' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Badge, Button, DateRangePicker, EmptyState, IconButton, LoadingBlock, PageHeader, QueryErrorState, SingleSelect, Table, TableBody, TableCell, TableEmpty, TableHead, TableHeaderCell, TableRow, TextInput, } from '@/ui' import { ArrowsClockwiseIcon, CaretDownIcon, CaretRightIcon, DownloadIcon, XIcon, } from '@phosphor-icons/react' 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' const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE'] const ACTION_VALUES: ReadonlySet = new Set(ACTIONS) const PAGE_SIZE_OPTIONS = [ { id: '50', label: '50' }, { id: '100', label: '100' }, { 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() // 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(() => { const opts: { id: string; label: string }[] = [{ id: '', label: '—' }] for (const d of dictionariesQuery.data ?? []) { opts.push({ id: d.name, label: d.displayName ?? d.name }) } return opts }, [dictionariesQuery.data]) const actionOptions = useMemo( () => [ { id: '', label: '—' }, ...ACTIONS.map((a) => ({ id: a, label: t(`audit.action.${a}`) })), ], [t], ) // navigate с replace=false — каждый Apply пишет history entry. // Reset → пустой URL без params. const apply = () => navigate({ search: filtersToSearch({ ...draft, page: 0 }), replace: false }) const reset = () => { 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 ? ( refetch()} /> ) : isLoading ? ( ) : !data || data.content.length === 0 ? ( ) : ( <> setPage(Math.max(0, currentPage - 1))} onNext={() => setPage(currentPage + 1)} onSizeChange={setSize} /> )} ) } 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 dictionaryOptions: { id: string; label: string }[] actionOptions: { id: string; label: string }[] onApply: () => void onReset: () => void } function FilterPanel({ filters, onChange, dictionaryOptions, actionOptions, onApply, onReset, }: FilterPanelProps) { const { t } = useTranslation() const set = (k: K, v: AuditFilters[K]) => onChange({ ...filters, [k]: v }) 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 (
set('dictionaryName', id || undefined)} /> set('action', (id as AuditAction) || '')} /> set('userId', e.target.value || undefined)} onKeyDown={handleEnter} /> set('businessKey', e.target.value || undefined)} onKeyDown={handleEnter} /> {/* DateRangePicker заменяет пару отдельных DatePicker'ов — один * popover с двумя месяцами, выбор start/end в одном UI. ISO sтроки * c local TZ offset формируются здесь, чтобы backend получал * правильный bounds (T00:00:00 для from, T23:59:59 для to). */}
{ const fromIso = from ? `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String( from.getDate(), ).padStart(2, '0')}T00:00:00${localTzOffset(from)}` : undefined const toIso = to ? `${to.getFullYear()}-${String(to.getMonth() + 1).padStart(2, '0')}-${String( to.getDate(), ).padStart(2, '0')}T23:59:59${localTzOffset(to)}` : undefined onChange({ ...filters, from: fromIso, to: toIso }) }} />
) } function AuditTable({ rows }: { rows: AuditEntry[] }) { const { t } = useTranslation() return ( {t('audit.col.time')} {t('audit.col.action')} {t('audit.col.dictionary')} {t('audit.col.businessKey')} {t('audit.col.user')} {t('audit.col.scope')} {t('audit.col.trace')} {t('audit.col.diff')} {rows.length === 0 ? ( {t('audit.empty')} ) : ( rows.map((r) => ) )}
) } function AuditRow({ row }: { row: AuditEntry }) { const { t } = useTranslation() const [open, setOpen] = useState(false) const time = new Date(row.eventTime) const timeLabel = time.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }) const actionVariant = row.action === 'CREATE' ? 'success' : row.action === 'CLOSE' ? 'error' : 'info' return ( <> {timeLabel} {t(`audit.action.${row.action}`, row.action)} {row.dictionaryName ?? '—'} {row.businessKey ?? '—'} {row.userScope ? {row.userScope} : null} {row.traceId ? row.traceId.slice(0, 8) : '—'} : } label={t('audit.action.expand')} onClick={() => setOpen((v) => !v)} /> {open && (
{t('audit.diff.before')}
                  {row.payloadBefore
                    ? JSON.stringify(row.payloadBefore, null, 2)
                    : '—'}
                
{t('audit.diff.after')}
                  {row.payloadAfter
                    ? JSON.stringify(row.payloadAfter, null, 2)
                    : '—'}
                
{(row.ipAddress || row.userAgent || row.requestId) && (
{row.ipAddress && IP: {row.ipAddress}} {row.requestId && req: {row.requestId}} {row.userAgent && ( UA: {row.userAgent} )}
)}
)} ) } type PaginationProps = { page: number totalPages: number size: number onPrev: () => void onNext: () => void onSizeChange: (size: number) => void } function Pagination({ page, totalPages, size, onPrev, onNext, onSizeChange, }: PaginationProps) { const { t } = useTranslation() return (
{t('audit.page.size')} onSizeChange(Number(id))} />
{t('audit.page.of', { cur: page + 1, total: Math.max(totalPages, 1) })}
) }