From 07a748be53b592903191445e3075efefe544db82 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 17:45:39 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(admin-ui):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20DayPicker'=D0=B0=20+=201=20=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=8F=D1=86=20=D0=B2=20PeriodFilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriodFilter рендерил DayPicker numberOfMonths={2} со стандартными rdp-* стилями — `.rdp-months` имеет `flex-wrap: wrap`, при недостатке ширины два месяца стакались вертикально, ячейки большие/разреженные, selected/hover состояний под наш accent не было. Выглядело rough. Два фикса: 1. PeriodFilter → numberOfMonths={1}. Для audit-фильтрации одного месяца достаточно (presets покрывают «7д/30д/Этот месяц»; календарь нужен для arbitrary range). Простой layout, не зависит от ширины popover'а. 2. Полноценные стили `.ord-day-picker` в styles.css (~80 строк): - tight cells (2rem × 2rem) - hover state на surface-2 - selected day → accent bg + on-accent text - range_middle → accent-bg, обнулённый radius - range_start/end → polo'асn'ое closure radius - today → 1px outline - outside-month days → mute opacity - nav buttons → accent цвет + hover - mono font на числа (JetBrains Mono), Inter на caption/weekday Бенефит распространяется и на DateRangePicker (использует тот же класс). flex-wrap: nowrap override — на случай если когда-то понадобится numberOfMonths={2} в другом контексте, не будет вертикального стека. --- .../src/components/audit/PeriodFilter.tsx | 2 +- ordinis-admin-ui/src/styles.css | 109 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx b/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx index e8a8a04..f8bc94b 100644 --- a/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx +++ b/ordinis-admin-ui/src/components/audit/PeriodFilter.tsx @@ -269,7 +269,7 @@ export function PeriodFilter({ value, onChange, label, disabled }: PeriodFilterP mode="range" selected={dpRange} onSelect={handleSelect} - numberOfMonths={2} + numberOfMonths={1} showOutsideDays weekStartsOn={1} className="ord-day-picker" diff --git a/ordinis-admin-ui/src/styles.css b/ordinis-admin-ui/src/styles.css index c7e0d2c..a8abdcd 100644 --- a/ordinis-admin-ui/src/styles.css +++ b/ordinis-admin-ui/src/styles.css @@ -443,3 +443,112 @@ body { animation: none; } } + +/* === react-day-picker — кастомизация под ordinis design system. + * Без этих overrides DayPicker рендерится дефолтными rdp-* стилями + * (большие квадратные ячейки, нет hover/selected состояний под наш + * accent). Класс `.ord-day-picker` ставится на корень DayPicker'а + * в DateRangePicker и PeriodFilter. === */ +.ord-day-picker { + --rdp-day-width: 2rem; + --rdp-day-height: 2rem; + --rdp-day_button-width: 2rem; + --rdp-day_button-height: 2rem; + --rdp-day_button-border-radius: 6px; + --rdp-months-gap: 1rem; + --rdp-nav_button-height: 1.75rem; + --rdp-nav_button-width: 1.75rem; + --rdp-weekday-text-transform: lowercase; + --rdp-weekday-padding: 0.25rem 0; + --rdp-month_caption-font: 600 0.875rem/1.25 'Inter', sans-serif; + --rdp-day-font: 400 0.8125rem/1 'JetBrains Mono', monospace; + --rdp-weekday-font: 500 0.7rem/1 'Inter', sans-serif; + --rdp-accent-color: var(--color-accent); + --rdp-accent-background-color: var(--color-accent-bg); + font-family: 'Inter', sans-serif; + color: var(--color-ink); +} + +/* Multi-month layout (если когда-то нужно): 2 столбца side-by-side + * вместо wrap'а вниз. */ +.ord-day-picker .rdp-months { + flex-wrap: nowrap; +} + +.ord-day-picker .rdp-month_caption { + padding: 0 0 0.5rem; + color: var(--color-ink); +} + +.ord-day-picker .rdp-weekday { + color: var(--color-mute); + text-transform: lowercase; + font-weight: 500; +} + +.ord-day-picker .rdp-day_button { + border-radius: 6px; + transition: background-color 120ms, color 120ms; +} + +.ord-day-picker .rdp-day_button:hover:not(:disabled):not([aria-disabled='true']) { + background-color: var(--color-surface-2); + color: var(--color-ink); +} + +.ord-day-picker .rdp-day.rdp-outside .rdp-day_button { + color: var(--color-mute); + opacity: 0.55; +} + +.ord-day-picker .rdp-day.rdp-today .rdp-day_button { + font-weight: 600; + outline: 1px solid var(--color-line-2, var(--color-line)); + outline-offset: -2px; +} + +/* Selected range */ +.ord-day-picker .rdp-day.rdp-selected .rdp-day_button { + background-color: var(--color-accent); + color: var(--color-on-accent, white); + font-weight: 500; +} + +.ord-day-picker .rdp-day.rdp-range_middle .rdp-day_button { + background-color: var(--color-accent-bg); + color: var(--color-accent); + border-radius: 0; +} + +.ord-day-picker .rdp-day.rdp-range_start .rdp-day_button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.ord-day-picker .rdp-day.rdp-range_end .rdp-day_button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.ord-day-picker .rdp-day.rdp-range_start.rdp-range_end .rdp-day_button { + border-radius: 6px; +} + +/* Nav buttons */ +.ord-day-picker .rdp-button_previous, +.ord-day-picker .rdp-button_next { + border-radius: 6px; + color: var(--color-accent); + transition: background-color 120ms; +} + +.ord-day-picker .rdp-button_previous:hover, +.ord-day-picker .rdp-button_next:hover { + background-color: var(--color-surface-2); +} + +.ord-day-picker .rdp-button_previous:disabled, +.ord-day-picker .rdp-button_next:disabled { + opacity: 0.35; + cursor: not-allowed; +} From 838138af5eec4cafdade630fec0f3e5d1704d0b1 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 17:50:23 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(admin-ui):=20UserPicker=20=E2=80=94=20T?= =?UTF-8?q?extInput=20fallback=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20cache=20?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Если /api/v1/admin/users возвращает [] (cache пустой / writer только-что рестартован / у юзера нет INTERNAL scope → 403 → catch → []), picker показывал «Пользователи не найдены» и фильтр становился недоступен. До MR !263 здесь был обычный TextInput, не лишаем юзера возможности вписать UUID руками. При isLoading=false, isError=false, data.length===0 — рендерим TextInput с подсказкой «Список юзеров пуст — введите вручную». Backend cache прогреется по мере того как юзеры будут делать authenticated requests (JwtUserCaptureFilter перепишет user_display_cache); тогда picker включится автоматически. --- .../src/components/audit/UserPicker.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/ordinis-admin-ui/src/components/audit/UserPicker.tsx b/ordinis-admin-ui/src/components/audit/UserPicker.tsx index f0da2f9..d6a0f17 100644 --- a/ordinis-admin-ui/src/components/audit/UserPicker.tsx +++ b/ordinis-admin-ui/src/components/audit/UserPicker.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { useMemo, useState } from 'react' import { CaretDownIcon, MagnifyingGlassIcon, UserIcon, XIcon } from '@phosphor-icons/react' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk' -import { Popover, PopoverTrigger, PopoverContent, FieldLabel } from '@/ui' +import { Popover, PopoverTrigger, PopoverContent, FieldLabel, TextInput } from '@/ui' import { useAllUsers, type UserListItem } from '@/api/queries' import { cn } from '@/lib/utils' @@ -38,11 +38,32 @@ export function UserPicker({ value, onChange, label, placeholder, disabled }: Us const [open, setOpen] = useState(false) const usersQ = useAllUsers() + // Fallback на TextInput если cache пустой или endpoint вернул 403/404 (юзер + // без INTERNAL scope, либо writer был только-что рестартован и cache не + // прогрелся). Без этого user-фильтр становится недоступен — раньше тут был + // TextInput, не лишаем юзера возможности вписать UUID руками. + const usersList = usersQ.data ?? [] + const fallbackToTextInput = + !usersQ.isLoading && !usersQ.isError && usersList.length === 0 + const usersBySub = useMemo(() => { const m = new Map() - for (const u of usersQ.data ?? []) m.set(u.sub, u) + for (const u of usersList) m.set(u.sub, u) return m - }, [usersQ.data]) + }, [usersList]) + + if (fallbackToTextInput) { + return ( + onChange(e.target.value || undefined)} + placeholder="UUID или username" + hint="Список юзеров пуст — введите вручную" + disabled={disabled} + /> + ) + } const selected = value ? usersBySub.get(value) : undefined const triggerLabel = useMemo(() => {