Цифра у иконки словарей показывала общее число справочников (~40 на проде).
Не меняется в течение работы юзера — не actionable, только визуальный шум.
Actionable badges остаются: «Мои черновики», «На ревью», «Outbox», «Webhooks» —
там цифра реально что-то означает (надо что-то сделать или разобраться).
Заодно убрал unused dictsQuery / dictsCount + import useDictionaries из обоих
сайдбаров (desktop + mobile). TSC noUnusedLocals чистый.
Badge в сайдбаре (desktop + mobile) показывал totalElements от /drafts/me
и /admin/schema-drafts/me — это сумма ВСЕХ статусов (PENDING + APPROVED +
REJECTED + WITHDRAWN). После approve/reject счётчик не уменьшался, юзер
видел «висящие» цифры на драфтах которые уже terminal.
Tab-бейдж в ReviewsPage давно делает правильно через classifyRecordStatus
/ classifySchemaStatus. Подтягиваем sidebar к той же логике:
- fetch size 50 вместо 1 (нужны .items[] для filter; TanStack дедуплицирует
query — на /reviews этот же ключ уже зовётся, лишних запросов нет)
- record → 'pending', schema → 'pending'|'wip'
- approved schema всё ещё попадает в pending по дизайну (maker должен
publish'нуть; см. classifySchemaStatus комментарий)
В collapsed-режиме (56px полоска) рендерили только иконку без label
и без badge — счётчик pending записей/драфтов был не виден, юзер не
понимал что есть необработанные элементы пока не раскроет сайдбар.
Теперь в collapsed: badge → маленький круглый pill в углу иконки
(top-right, 16×16, ring-1 от bg чтобы хорошо отделялся от иконки).
Cap "99+" если число большое — uniform width. Tooltip на link
включает label + (count) при hover.
Expanded-mode без изменений — badge справа от label как был.
1. useApproveDraft/useRejectDraft теперь инвалидируют ['drafts'] key
(my-drafts + by-dict lists). До этого инвалидировался только ['draft']
(single by id), поэтому after-approve "Мои" badge продолжал показывать
pending-счётчик даже когда все драфты уже APPROVED — useMyDrafts отдавал
стейл-кеш. useWithdrawDraft уже это делал, теперь подтянули approve/reject.
2. DictionaryEditorDialog: на десктопе (≥880px) modal теперь max-w 1400px
вместо max-w-5xl (880px после маппинга в SIZE_CLASSES.xl). На <880px
осталось w-[95vw] (compact mobile modal). На 1440p мониторе editor
больше не выглядит вжатым, tabs+schema+preview помещаются без скролла.
Override через panelClassName c `!` чтобы перебить SIZE_CLASSES.
Три проблемы по reviews-drawer (по фидбеку: «Открыть ломается / Согласование
не показывает справочник / не показывает live / JSON не подсвечен»):
1. useLiveRecord(draft.dictionaryId, ...) → useLiveRecord(dictName, ...).
Endpoint /api/v1/dictionaries/{name}/records/{bk} ожидает name, не UUID;
до фикса валился с 404 «Dictionary not found: <uuid>» → live-блок был
пустой и показывался "noLive" даже для UPDATE/CLOSE drafts.
2. Header drawer'а теперь рендерит "<dictName> / <businessKey>" вместо
просто businessKey. dictName — link на /dictionaries/$name. Fallback
на UUID-prefix без link'а если dict не виден (scope-hide).
3. JsonView (новый компонент, ~120 строк, без deps) заменяет голые <pre>
JSON.stringify(...):
- syntax highlight: ключи (aurora), строки (pink), числа (warn),
true/false/null (accent), пунктуация (mute)
- diff highlight: top-level ключи из shallowDiffSummary получают
фон+border (зелёный added / красный removed / жёлтый changed).
В live-колонке — removed+changed, в proposed — added+changed.
Также добавлен error-state для liveQ (раньше 404 от useLiveRecord на UUID
молча превращался в "noLive"; теперь explicit "не удалось загрузить").
В таблицах /reviews (Запись + Мои → Запись) колонка «Объект» показывала
UUID-prefix («254a662f…/REFLEXIO-OPTIC») вместо имени словаря, а ссылка
вела на /dictionaries/<uuid> — 404 (route ожидает name, не UUID).
DraftResponse несёт только dictionaryId (UUID); dictionaryName есть только
у schema-drafts. Резолвим через useDictionaries() — кешируется TanStack
Query, дополнительных запросов не делает.
Fallback на UUID-prefix без link'а, когда dict не в выдаче (scope-hide /
удалён) — лучше чем ведущая в 404 ссылка.
Files:
- routes/reviews.tsx — records-tab table (queue, ReviewsPage)
- components/reviews/MyDraftsPanel.tsx — RecordRow ("Мои" tab)
Три массива в instrument были comma-separated текстом с подсказкой
«businessKey'и из X». Пользователь не знал что туда вписать — справочника
доступных значений рядом нет.
UI: добавлен ReferenceMultiSelectField — array-аналог ReferenceSelectField.
Триггер — `items["x-references"]` на string-массиве. Под капотом — готовый
MultiSelect (popover + cmdk-поиск). Fallback на comma-separated TextInput
сохраняется когда target dict недоступен (scope-hide / 403 / 404), сервер
всё равно валидирует FK через ReferenceValidator.
Schema:
- spacecraft_codes → items.x-references: spacecraft.designator
- supported_format_codes → items.x-references: data_format.code
- supported_level_codes → items.x-references: processing_level.code
instrument schemaVersion 1.0.0 → 1.1.0, bundle 1.3.2 → 1.3.3 —
CuodBundleImporter перепишет schema_json в БД на старте writer'а,
admin-ui подхватит свежую схему через /api/v1/dictionaries/instrument.
Tests: 2 новых кейса в SchemaDrivenForm.test.tsx — happy path (MultiSelect
рендерится, запрос ушёл на правильный dict) + fallback (comma-separated
с подсказкой «список недоступен»). 171/171 passed.
1. reviews.tsx: ReviewDrawer звал useLiveRecord для ЛЮБОГО черновика,
включая CREATE. У CREATE-черновика live-записи нет по определению →
GET /dictionaries/{id}/records/{key} → 404 (красный шум в консоли на
каждый CREATE-draft). Теперь для CREATE запрос не запускается, блок
«текущая версия» показывает «нет текущей версии».
2. modal.tsx: Modal рендерил DialogTitle только при наличии prop title.
Без title Radix писал в консоль «DialogContent requires a DialogTitle»
+ warning про aria-describedby. Теперь Modal всегда рендерит DialogTitle
(sr-only fallback) и гасит aria-describedby когда описания нет.
Принцип: клик по «Сохранить» не должен оставаться без реакции.
Раньше submit мог молча оборваться: AJV-ошибка ложилась через setError
на nested-путь (data.<field>.<locale>), а FieldError читает data.<field>
→ ошибка невидима, форма «не реагирует».
Добавлено:
- blockedMsg — состояние с причиной, рендерится видимым Alert сверху
формы при любой блокировке submit (validFrom/validTo, AJV).
- onInvalid — обработчик RHF handleSubmit на случай когда RHF сам
блокирует submit (required-поля); тоже ставит blockedMsg.
- scrollIntoView к началу формы — Alert гарантированно в зоне видимости.
Теперь Save либо сохраняет, либо показывает «Не удалось сохранить —
проверьте поля: …». Молчания не будет.
Симптом: «Сохранить» (create и edit) ничего не делал, ошибок не видно.
Причина: x-localized поле рендерит Controller на каждую локаль
(data.<field>.<locale>). RHF при сабмите реконструирует values из имён
зарегистрированных полей и материализует родительский объект даже когда
исходное значение было null/отсутствовало → optional поле (например
mission) превращалось в { "ru-RU": undefined, "en-US": undefined }.
AJV отвергал undefined-локали ("must be string"), submit() делал return
до onSubmit — запрос не уходил. Ошибка ложилась через setError на
data.<field>.<locale>, а FieldError читает data.<field> → невидима.
Фикс: pruneEmpty() в submit() рекурсивно убирает undefined и опустевшие
объекты из data перед AJV-валидацией и отправкой — нетронутое
optional-поле снова валидируется как «отсутствует». Не трогает
null/''/0/false.
Откат временной диагностики [ordinis-diag] из MR !251.
Regression-тест падает без фикса, проходит с фиксом.
ВРЕМЕННО — для поиска причины «Save ничего не делает». Откатить
после диагностики (все логи помечены [ordinis-diag]).
SchemaDrivenForm: console.warn в submit() — событие формы, RHF
onInvalid, результат AJV, ветки return, вызов onSubmit.
dictionaries.$name: логи в route.handleSubmit + createMut/updateMut
mutate + onSuccess/onError со статусом ответа.
Также добавлен постоянный регресс-тест: повторный submit после
исправления валидации вызывает onSubmit.
Top-left dark-glass input на странице /graph. Substring match
case-insensitive по name + displayName (admin помнит либо
technical id `antenna`, либо русское название `Антенны`).
UX:
- Matched nodes остаются в full opacity, остальные dim'ятся (0.25) —
как при hover-focus. Force-layout не трогаем (positions стабильны).
- При единственном match → matched node получает focused border
(orange accent + glow) — легко найти результат.
- Edges светятся только когда оба конца — match. Иначе dim.
- Counter chip `N из M` под input'ом + `ничего не найдено` для empty.
- Esc clears query и снимает focus. ✕-кнопка тоже clears (focus
остаётся на input).
- Search dominates над hover dim — non-matched nodes остаются dim
даже если hover их затрагивает.
i18n: graph.search.placeholder / .empty / .count для ru-RU и en-US.
Force-simulation не пересчитываем при изменении query — это намеренно.
Иначе nodes прыгали бы по экрану на каждый символ, ломая spatial
recognition (юзер уже привык где какой node лежит).
Восемь дефектов из QA-отчёта одним MR.
#1 — Records-table теряла первую колонку после закрытия Create-modal.
Корень: lg:grid-cols-[220px_1fr] без minmax(0,…) — 1fr имел implicit
min-content (=max-content), позволяя table вырасти шире viewport.
После закрытия Radix Dialog снималось body padding-right (~15px), grid
пересчитывался и клиппил колонку под overflow-hidden card.
Fix: grid-cols-[220px_minmax(0,1fr)] + убран overflow-hidden с card
(Table primitive имеет свой overflow-x-auto).
#2 — Esc закрывал modal с задержкой (визуал/DOM рассинхрон).
duration-200 на slow CPU ощущался как несколько кадров между state=closed
и unmount. Снижено до duration-150 — совпадает с tw-animate-css дефолтами.
#3 — «+ Создать» обрезалось до «+ Соз» на ~1075px на detail-странице.
EditorTabBar: 6 tabs + 3 кнопки actions в одном flex row без shrink-0
давили друг друга. Решение: tabs в overflow-x-auto wrapper с min-w-0,
actions в shrink-0 контейнере — tabs скроллятся, actions всегда видны
полностью. scrollbar скрыт визуально (height-консистентность).
#4 — Обрезка названий в records-table при PUBLIC+INTERNAL.
Добавлен div.overflow-x-auto вокруг table + min-w-[760px] на table +
min-w-[180px] max-w-[320px] truncate на name cell. Catalog table также
получает min-w-[220px] на name column.
#5 — Заголовки колонок и кнопка «Граф» не локализованы на EN.
defaultValue:'Название' inline в коде падал на ru-text при EN locale.
Вынесены в i18n: dict.col.title/id/bundle/records/updated/outgoingFk/
incomingFk + dict.list.graph для обеих локалей.
#6 — Modal backdrop почти невидим.
bg-black/40 + 1px blur → bg-black/55 + 2px blur. Заметно темнее при
сохранении читабельности фона, фокус на modal.
#7 — Неестественная подсказка в форме «Создать»:
«Имя оставьте уникальное; версия сбросится на 1.0.0» →
«Укажите уникальное имя — версия сбросится на 1.0.0».
#8 — «Главная» в сайдбаре сбивала active state.
Клик на «Главную» вёл на `/` → redirect на /dictionaries → подсветка
уезжала на «Справочники». Logo сверху уже даёт home affordance, поэтому
duplicate nav item удалён (desktop + mobile).
После !234 ship'нул AiSchemaController на writer, frontend hit /api/v1/ai/info
получает 500 — generic /api/v1/ location route к read-api который не имеет
этих handlers. Тот же pattern fix что для /drafts/, /me/, /admin/ — explicit
location к writer перед generic /api/v1/ fallback.
Без этого AI feature unusable в staging (даже если backend healthy).
Last item из QA report backlog'а. Banner + empty-state hint показывают
aggregate count, но не помогают find конкретные записи которые скоро
изменятся. Badge per row даёт O(1) visual scan.
Backend (read-api):
- ScheduledRecordsSummary.scheduledBusinessKeys: List<String> (capped 500)
- Repo method findScheduledBusinessKeys (DISTINCT subquery)
- Single fetch вместо N+1 per-row queries
Frontend:
- scheduledByKey Set<string> построен из summary
- Badge variant=info «Запланировано» рядом с businessKey badge'ом
- Title attribute с full RU/EN tooltip
- Не блокирует layout — gap-1.5 с pendingReview badge'ем (могут быть оба)
i18n keys для ru/en — short label «Запланировано» / «Scheduled».
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.
Form review после !224 показал что create mode имеет тот же баг что и edit:
SchemaDrivenForm без переданного defaultValues fallback'ит на '' для
validFrom, и пользователь видит пустое поле «Действует с».
Добавлен defaultValues={{ validFrom: nowIsoLocal() }} для create — consistent
UX с edit mode (!224 fix). Other formы (drafts, reviews, schema workflow) —
либо read-only либо без validFrom, clean.
User feedback: «крайняя дата должна быть исходя из реальной в этом
словаре». Раньше slider всегда extended до now + 30d даже без причины —
если нет scheduled записей на 18.05, slider всё равно тянулся туда,
пользователь блуждал по пустоте.
Теперь max = max(marks) — last real change point (record validFrom включая
upcoming scheduled, которые backend возвращает в /scheduled-summary).
Если все marks в прошлом — max=now (always reachable). Если есть future
scheduled на 18.05 — slider тянется ровно туда, не дальше.
Touches: TimeTravelPicker.tsx (inline editor) + TimeTravelModal.tsx (modal).
Три связанных UX бага после v2.31.0 release:
1. **TimeTravel marks**: после !220 slider extended до now+30d, но отметок в
future window не было → blind drag. Backend теперь возвращает
upcomingValidFroms (top 50) в /scheduled-summary; frontend merges как marks.
2. **Non-empty banner**: empty-state hint работает только когда список пуст.
Если currently active записи есть AND ещё запланированы — пользователь
не видит. Добавлен info banner над таблицей с тем же текстом + CTA.
3. **Editor "Действует с"**: edit mode оставлял поле пустым (bitemporal
policy «новая версия с now»). User feedback: «показывает ничего, а даты
заданы» — выглядит как баг. Prefill nowIsoLocal() — теперь юзер видит
конкретную дату как точку отсчёта.
MR !220 расширил range у TimeTravelPicker.tsx, но юзер видит другой компонент —
TimeTravelModal.tsx (fullscreen modal с компарисоном now vs picked moment).
У него своя range logic с max = now + pad (1 day), и фикс !220 туда не дотянулся.
User feedback: создал запись с validFrom=18.05, открыл time-travel modal — слайдер
14.05 → 16.05, не дотянуть до 18-го.
Apply same 30-day future window logic к TimeTravelModal так чтобы оба компонента
вели себя одинаково.