cb8a229adf
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.
610 lines
20 KiB
TypeScript
610 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|