diff --git a/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx b/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx new file mode 100644 index 0000000..e8a8a04 --- /dev/null +++ b/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx @@ -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. + * + *

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 ( +

+ {label && {label}} + + + + )} + + + + + + +
+ {/* Presets list */} +
+ {PRESETS.map((p) => ( + + ))} + +
+ {/* Calendar (custom range mode) */} + {(showCalendar || (!activePreset && hasValue)) && ( +
+ + Загрузка календаря… +
+ } + > + + +
+ )} +
+ + + + ) +} diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx index 3ade8a2..8a4857b 100644 --- a/ordinis-admin-ui/src/routes/audit.tsx +++ b/ordinis-admin-ui/src/routes/audit.tsx @@ -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). */} - {