Files
mdm-ordinis/ordinis-admin-ui/src/routes/audit.tsx
T
Zimin A.N. cb8a229adf fix(ui): QA report P3/P4 batch — 7 cosmetic/UX bugs
QA report cleanup (после P1/P2 ship'нутых в v2.31.4):

- **BUG-009**: Excel/SQL export форматы получили явный «скоро» badge поверх
  opacity-only disabled state. Title attribute оставлен.
- **BUG-010**: «Доступ актора» (audit scope column) переименован в «Scope» —
  компактнее, не переносится на 2 строки.
- **BUG-011**: Все audit table headers получили title attribute → hover
  tooltip когда text truncate'ится («ИЗМЕНЕН…» → full «Изменения»).
- **UX-001**: Scope chips в /dictionaries теперь показывают ✓ glyph когда
  selected → визуально clear что multi-select. Aria-label расширен:
  «Фильтр по области (можно выбрать несколько)».
- **UX-002**: Breadcrumb segments получили title attribute → hover показывает
  full label когда truncate'ится в narrow viewport.
- **UX-003**: При смене вкладки в редакторе справочника (records → relations
  и т.п.) URL params q/scopes сбрасываются — раньше search фильтр оставался
  применённым после возврата на records.
- **UX-004**: TimeTravel slider boundary метки получили 2-digit year:
  «05.05.26» вместо «05.05» — disambiguates конец декабря / начало января.
  Title attribute exposes full локальную дату.
- **UX-009**: «Удалить» button в RecordDrawer переименован в «Закрыть запись»
  — same action как row context menu «Закрыть» (bitemporal close, не truly
  delete). Consistent labeling across UI.
2026-05-16 00:09:41 +03:00

610 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// Все 12 audit action types — должны matchить backend (AuditLogger,
// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
// QA report BUG-004: фильтр предлагал только CREATE/UPDATE/CLOSE → DRAFT_*
// и DEFINITION_* events не были фильтруемы через UI.
const ACTIONS: AuditAction[] = [
'CREATE',
'UPDATE',
'CLOSE',
'DEFINITION_CREATE',
'DEFINITION_UPDATE',
'DRAFT_CREATE',
'DRAFT_SUBMIT',
'DRAFT_REVIEW',
'DRAFT_APPROVE',
'DRAFT_REJECT',
'DRAFT_CHANGES_REQUESTED',
'DRAFT_WITHDRAW',
'DRAFT_PUBLISH',
]
const ACTION_VALUES: ReadonlySet<AuditAction> = 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<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()
// 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(() => {
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 (
<div className="space-y-6">
<PageHeader
title={t('audit.title')}
description={t('audit.subtitle')}
actions={
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
leftIcon={<ArrowsClockwiseIcon size={16} />}
onClick={() => refetch()}
disabled={isFetching}
>
{t('audit.filter.apply')}
</Button>
<a
href={buildAuditExportUrl(filters)}
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button
type="button"
variant="primary"
leftIcon={<DownloadIcon size={16} />}
>
{t('audit.action.export')}
</Button>
</a>
</div>
}
/>
<FilterPanel
filters={draft}
onChange={setDraft}
dictionaryOptions={dictionaryOptions}
actionOptions={actionOptions}
onApply={apply}
onReset={reset}
/>
{activeFilters.length > 0 && (
<ActiveFilterChips filters={activeFilters} onRemove={removeFilter} />
)}
{error ? (
<QueryErrorState error={error} onRetry={() => refetch()} />
) : isLoading ? (
<LoadingBlock size="md" label={t('loading')} />
) : !data || data.content.length === 0 ? (
<EmptyState title={t('audit.empty')} />
) : (
<>
<AuditTable rows={data.content} />
<Pagination
page={currentPage}
totalPages={totalPages}
size={filters.size ?? 50}
onPrev={() => setPage(Math.max(0, currentPage - 1))}
onNext={() => setPage(currentPage + 1)}
onSizeChange={setSize}
/>
</>
)}
</div>
)
}
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-cap text-ink-2">
{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-cell',
'bg-surface border-line hover:border-ink/40 hover:bg-line/30',
'transition-colors group',
].join(' ')}
title={t('audit.filter.removeChip')}
>
<span className="text-ink-2">{f.label}:</span>
<span className="font-medium text-black">{f.value}</span>
<XIcon size={12} className="text-mute group-hover:text-mars" />
</button>
))}
</div>
)
}
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 extends keyof AuditFilters>(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<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onApply()
}
}
return (
<div className="bg-surface border border-line rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<SingleSelect
label={t('audit.filter.dictionary')}
options={dictionaryOptions}
value={filters.dictionaryName ?? ''}
onChange={(id) => set('dictionaryName', id || undefined)}
/>
<SingleSelect
label={t('audit.filter.action')}
options={actionOptions}
value={filters.action ?? ''}
onChange={(id) => set('action', (id as AuditAction) || '')}
/>
<TextInput
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}
/>
{/* DateRangePicker заменяет пару отдельных DatePicker'ов — один
* popover с двумя месяцами, выбор start/end в одном UI. ISO sтроки
* c local TZ offset формируются здесь, чтобы backend получал
* правильный bounds (T00:00:00 для from, T23:59:59 для to). */}
<div className="md:col-span-2">
<DateRangePicker
label={t('audit.filter.range', { defaultValue: 'Период' })}
value={{ from: fromDate ?? undefined, to: toDate ?? undefined }}
onChange={({ from, 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 })
}}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button type="button" variant="secondary" onClick={onReset}>
{t('audit.filter.reset')}
</Button>
<Button type="button" variant="primary" onClick={onApply}>
{t('audit.filter.apply')}
</Button>
</div>
</div>
)
}
function AuditTable({ rows }: { rows: AuditEntry[] }) {
const { t } = useTranslation()
return (
<Table>
<TableHead>
<TableRow>
{/* QA BUG-011 fix: title attribute → hover tooltip когда text truncate'ится. */}
<TableHeaderCell title={t('audit.col.time')}>{t('audit.col.time')}</TableHeaderCell>
<TableHeaderCell title={t('audit.col.action')}>{t('audit.col.action')}</TableHeaderCell>
<TableHeaderCell title={t('audit.col.dictionary')}>{t('audit.col.dictionary')}</TableHeaderCell>
<TableHeaderCell title={t('audit.col.businessKey')}>{t('audit.col.businessKey')}</TableHeaderCell>
<TableHeaderCell title={t('audit.col.user')}>{t('audit.col.user')}</TableHeaderCell>
<TableHeaderCell title={t('audit.col.scope')}>{t('audit.col.scope')}</TableHeaderCell>
<TableHeaderCell title={t('audit.col.trace')}>{t('audit.col.trace')}</TableHeaderCell>
<TableHeaderCell title={t('audit.col.diff')}>{t('audit.col.diff')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{rows.length === 0 ? (
<TableEmpty colSpan={8}>{t('audit.empty')}</TableEmpty>
) : (
rows.map((r) => <AuditRow key={r.id} row={r} />)
)}
</TableBody>
</Table>
)
}
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 (
<>
<TableRow>
<TableCell>
<span className="text-cell tabular-nums">{timeLabel}</span>
</TableCell>
<TableCell>
<Badge variant={actionVariant}>
{t(`audit.action.${row.action}`, row.action)}
</Badge>
</TableCell>
<TableCell>{row.dictionaryName ?? '—'}</TableCell>
<TableCell>
<span className=" text-mono">{row.businessKey ?? '—'}</span>
</TableCell>
<TableCell><UserCell uuid={row.userId} /></TableCell>
<TableCell>
{row.userScope ? <Badge variant="info">{row.userScope}</Badge> : null}
</TableCell>
<TableCell>
<span
className="text-mono text-ink-2"
title={row.traceId ?? ''}
>
{row.traceId ? row.traceId.slice(0, 8) : '—'}
</span>
</TableCell>
<TableCell>
<IconButton
type="button"
icon={open ? <CaretDownIcon /> : <CaretRightIcon />}
label={t('audit.action.expand')}
onClick={() => setOpen((v) => !v)}
/>
</TableCell>
</TableRow>
{open && (
<TableRow>
<TableCell colSpan={8}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 bg-line/30 rounded p-3">
<div>
<div className="text-cap text-ink-2 mb-1">
{t('audit.diff.before')}
</div>
<pre className="text-mono whitespace-pre-wrap break-all bg-surface border border-line rounded p-2 max-h-60 overflow-auto">
{row.payloadBefore
? JSON.stringify(row.payloadBefore, null, 2)
: '—'}
</pre>
</div>
<div>
<div className="text-cap text-ink-2 mb-1">
{t('audit.diff.after')}
</div>
<pre className="text-mono whitespace-pre-wrap break-all bg-surface border border-line rounded p-2 max-h-60 overflow-auto">
{row.payloadAfter
? JSON.stringify(row.payloadAfter, null, 2)
: '—'}
</pre>
</div>
{(row.ipAddress || row.userAgent || row.requestId) && (
<div className="md:col-span-2 text-cell text-ink-2 flex flex-wrap gap-x-4 gap-y-1 pt-1">
{row.ipAddress && <span>IP: {row.ipAddress}</span>}
{row.requestId && <span>req: {row.requestId}</span>}
{row.userAgent && (
<span className="truncate max-w-md">UA: {row.userAgent}</span>
)}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
}
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 (
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2 text-body">
<span className="text-ink-2">{t('audit.page.size')}</span>
<SingleSelect
options={PAGE_SIZE_OPTIONS}
value={String(size)}
onChange={(id) => onSizeChange(Number(id))}
/>
</div>
<div className="flex items-center gap-3 text-body">
<Button
type="button"
variant="secondary"
onClick={onPrev}
disabled={page <= 0}
>
{t('audit.page.prev')}
</Button>
<span className="tabular-nums">
{t('audit.page.of', { cur: page + 1, total: Math.max(totalPages, 1) })}
</span>
<Button
type="button"
variant="secondary"
onClick={onNext}
disabled={page + 1 >= totalPages}
>
{t('audit.page.next')}
</Button>
</div>
</div>
)
}