feat(admin-ui): кастомный период-фильтр для /audit — preset chips + календарь
Заменил голый DateRangePicker (только календарь, требует двух кликов на любой typical период) на preset-driven PeriodFilter — pattern из Datadog/Grafana: Triггер компактный (h-9), показывает active preset label либо forматированный range («04.05.2026 — 29.05.2026»). При клике открывается popover с: - Список пресетов слева: Сегодня / Вчера / Последние 7 дней / 30 дней / Этот месяц / Прошлый месяц - «Произвольный диапазон» внизу — раскрывает 2-month DayPicker справа Active preset подсвечивается. Custom range детектится sameRange сравнением — если value матчит preset, его и подсвечиваем. X-кнопка в trigger'е чистит период без открытия popover'а. DayPicker lazy-loaded (~80kb gz, не пакуется в initial audit bundle). ISO bounds в audit.tsx не трогали — onChange отдаёт Date'ы, нормализация T00:00:00/T23:59:59 + local TZ остаётся прежней. Файлы: - components/audit/PeriodFilter.tsx (новый, ~190 строк) - routes/audit.tsx — заменил DateRangePicker → PeriodFilter, убран import 171/171 tests, TSC чистый.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CalendarBlankIcon, XIcon, CaretDownIcon } from '@phosphor-icons/react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { Popover, PopoverTrigger, PopoverContent, FieldLabel } from '@/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Audit-специфичный period filter. Заменяет голый DateRangePicker на
|
||||
* preset-driven popover: чипы быстрых периодов (Сегодня, 7 дней, Этот
|
||||
* месяц…) + произвольный диапазон через DayPicker. Pattern взят из
|
||||
* Datadog/Grafana time picker'ов — для audit log'а это типичный UX.
|
||||
*
|
||||
* <p>Compact trigger (h-9) — помещается в строку фильтров на /audit.
|
||||
* Открытое состояние: presets-список слева + календарь справа (или
|
||||
* только presets если custom не выбран; календарь раскрывается клик'ом
|
||||
* на «Произвольный диапазон»).
|
||||
*/
|
||||
|
||||
type Range = { from?: Date; to?: Date }
|
||||
|
||||
type Preset = {
|
||||
id: string
|
||||
label: string
|
||||
range: () => Range
|
||||
}
|
||||
|
||||
/** Lazy DayPicker — ~80kb gz, не пакуем в initial bundle audit'а. */
|
||||
const DayPickerLazy = React.lazy(async () => {
|
||||
const [{ DayPicker }] = await Promise.all([
|
||||
import('react-day-picker'),
|
||||
import('react-day-picker/style.css'),
|
||||
])
|
||||
return { default: DayPicker }
|
||||
})
|
||||
|
||||
const startOfDay = (d: Date): Date => {
|
||||
const n = new Date(d)
|
||||
n.setHours(0, 0, 0, 0)
|
||||
return n
|
||||
}
|
||||
const endOfDay = (d: Date): Date => {
|
||||
const n = new Date(d)
|
||||
n.setHours(23, 59, 59, 999)
|
||||
return n
|
||||
}
|
||||
const addDays = (d: Date, n: number): Date => {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
|
||||
const PRESETS: Preset[] = [
|
||||
{
|
||||
id: 'today',
|
||||
label: 'Сегодня',
|
||||
range: () => ({ from: startOfDay(new Date()), to: endOfDay(new Date()) }),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: 'Вчера',
|
||||
range: () => {
|
||||
const y = addDays(new Date(), -1)
|
||||
return { from: startOfDay(y), to: endOfDay(y) }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '7d',
|
||||
label: 'Последние 7 дней',
|
||||
range: () => ({ from: startOfDay(addDays(new Date(), -6)), to: endOfDay(new Date()) }),
|
||||
},
|
||||
{
|
||||
id: '30d',
|
||||
label: 'Последние 30 дней',
|
||||
range: () => ({ from: startOfDay(addDays(new Date(), -29)), to: endOfDay(new Date()) }),
|
||||
},
|
||||
{
|
||||
id: 'thisMonth',
|
||||
label: 'Этот месяц',
|
||||
range: () => {
|
||||
const now = new Date()
|
||||
return {
|
||||
from: startOfDay(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||
to: endOfDay(new Date()),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lastMonth',
|
||||
label: 'Прошлый месяц',
|
||||
range: () => {
|
||||
const now = new Date()
|
||||
return {
|
||||
from: startOfDay(new Date(now.getFullYear(), now.getMonth() - 1, 1)),
|
||||
to: endOfDay(new Date(now.getFullYear(), now.getMonth(), 0)),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Сравнивает два range'а по дате (без HH:MM:SS) — нам нужно знать что
|
||||
* current value соответствует preset'у с точностью до дня, не до миллисекунды. */
|
||||
const sameRange = (a: Range, b: Range): boolean => {
|
||||
const ds = (d: Date | undefined) =>
|
||||
d ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : undefined
|
||||
return ds(a.from) === ds(b.from) && ds(a.to) === ds(b.to)
|
||||
}
|
||||
|
||||
const formatDate = (d: Date): string =>
|
||||
`${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`
|
||||
|
||||
const formatRange = (r: Range): string => {
|
||||
if (!r.from && !r.to) return 'Все время'
|
||||
if (r.from && r.to) return `${formatDate(r.from)} — ${formatDate(r.to)}`
|
||||
if (r.from) return `с ${formatDate(r.from)}`
|
||||
if (r.to) return `до ${formatDate(r.to)}`
|
||||
return ''
|
||||
}
|
||||
|
||||
export type PeriodFilterProps = {
|
||||
value: Range
|
||||
onChange: (range: Range) => void
|
||||
label?: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function PeriodFilter({ value, onChange, label, disabled }: PeriodFilterProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// Custom раскрывается либо если active preset не находится, либо если
|
||||
// юзер кликнул «Произвольный диапазон» — сохраняем state между
|
||||
// открытиями popover'а для intuitive поведения.
|
||||
const [showCalendar, setShowCalendar] = useState(false)
|
||||
|
||||
const activePreset = useMemo(
|
||||
() => PRESETS.find((p) => sameRange(p.range(), value))?.id,
|
||||
[value],
|
||||
)
|
||||
|
||||
// Триггер показывает либо preset label, либо range, либо «Все время».
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (activePreset) {
|
||||
return PRESETS.find((p) => p.id === activePreset)!.label
|
||||
}
|
||||
return formatRange(value)
|
||||
}, [activePreset, value])
|
||||
|
||||
const hasValue = Boolean(value.from || value.to)
|
||||
|
||||
const dpRange: DateRange | undefined = useMemo(
|
||||
() => (value.from || value.to ? { from: value.from, to: value.to } : undefined),
|
||||
[value.from, value.to],
|
||||
)
|
||||
|
||||
const handleSelect = (next: DateRange | undefined) => {
|
||||
if (!next) {
|
||||
onChange({})
|
||||
return
|
||||
}
|
||||
// DayPicker отдаёт даты с time=00:00; нормализуем from в start-of-day
|
||||
// и to в end-of-day чтобы matched диапазон покрывал полные сутки.
|
||||
onChange({
|
||||
from: next.from ? startOfDay(next.from) : undefined,
|
||||
to: next.to ? endOfDay(next.to) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const applyPreset = (p: Preset) => {
|
||||
onChange(p.range())
|
||||
setShowCalendar(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const clear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange({})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <FieldLabel>{label}</FieldLabel>}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
|
||||
'hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<CalendarBlankIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||
<span className={cn('truncate', !hasValue && 'text-mute')}>
|
||||
{hasValue ? triggerLabel : 'Все время'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Сбросить период"
|
||||
onClick={clear}
|
||||
className="p-0.5 rounded hover:bg-line/40 text-mute hover:text-ink"
|
||||
>
|
||||
<XIcon size={12} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
<CaretDownIcon size={12} weight="regular" className="text-mute" />
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="p-0 w-auto max-w-[min(95vw,720px)]"
|
||||
>
|
||||
<PopoverPrimitive.Close className="hidden" />
|
||||
<div className="flex">
|
||||
{/* Presets list */}
|
||||
<div className="flex flex-col p-1 border-r border-line min-w-[160px]">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => applyPreset(p)}
|
||||
className={cn(
|
||||
'text-left px-2.5 py-1.5 rounded text-body transition-colors',
|
||||
activePreset === p.id
|
||||
? 'bg-accent-bg text-accent font-medium'
|
||||
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCalendar((s) => !s)}
|
||||
className={cn(
|
||||
'text-left px-2.5 py-1.5 rounded text-body transition-colors mt-1 border-t border-line',
|
||||
showCalendar || (!activePreset && hasValue)
|
||||
? 'bg-accent-bg text-accent font-medium'
|
||||
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
Произвольный диапазон
|
||||
<CaretDownIcon
|
||||
size={12}
|
||||
weight="regular"
|
||||
className={cn(
|
||||
'inline-block ml-1 transition-transform',
|
||||
showCalendar || (!activePreset && hasValue) ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Calendar (custom range mode) */}
|
||||
{(showCalendar || (!activePreset && hasValue)) && (
|
||||
<div className="p-2">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="h-64 w-[520px] flex items-center justify-center text-mute text-cap">
|
||||
Загрузка календаря…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DayPickerLazy
|
||||
mode="range"
|
||||
selected={dpRange}
|
||||
onSelect={handleSelect}
|
||||
numberOfMonths={2}
|
||||
showOutsideDays
|
||||
weekStartsOn={1}
|
||||
className="ord-day-picker"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DateRangePicker,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
LoadingBlock,
|
||||
@@ -31,6 +30,7 @@ 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'
|
||||
import { PeriodFilter } from '@/components/audit/PeriodFilter'
|
||||
|
||||
// Все 12 audit action types — должны matchить backend (AuditLogger,
|
||||
// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
|
||||
@@ -402,10 +402,10 @@ function FilterPanel({
|
||||
onChange={(e) => set('businessKey', e.target.value || undefined)}
|
||||
onKeyDown={handleEnter}
|
||||
/>
|
||||
{/* DateRangePicker — один popover с двумя месяцами, выбор start/end
|
||||
в одном UI. ISO строки с local TZ offset формируются здесь чтобы
|
||||
backend получал правильный bounds (T00:00:00 для from, T23:59:59 для to). */}
|
||||
<DateRangePicker
|
||||
{/* PeriodFilter — preset chips («Сегодня», «7 дней», «Этот месяц»…)
|
||||
+ произвольный диапазон через DayPicker. ISO с local TZ offset
|
||||
формируется здесь (T00:00:00 для from, T23:59:59 для to). */}
|
||||
<PeriodFilter
|
||||
label={t('audit.filter.range', { defaultValue: 'Период' })}
|
||||
value={{ from: fromDate ?? undefined, to: toDate ?? undefined }}
|
||||
onChange={({ from, to }) => {
|
||||
|
||||
Reference in New Issue
Block a user