Commit Graph

255 Commits

Author SHA1 Message Date
Zimin A.N. 838138af5e fix(admin-ui): UserPicker — TextInput fallback когда cache пустой
Если /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
включится автоматически.
2026-05-25 17:50:23 +03:00
Zimin A.N. 07a748be53 fix(admin-ui): полноценные стили DayPicker'а + 1 месяц в PeriodFilter
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} в другом контексте, не будет вертикального стека.
2026-05-25 17:45:39 +03:00
Zimin A.N. 3f05741ed1 feat: /audit поле «Пользователь» — typeahead picker из user-cache
Backend: GET /api/v1/admin/users (INTERNAL+ scope) — list cached юзеров
из user_display_cache. UserDisplayService.listAll(limit) сортирует по
preferred_username, hard-cap 1000. Контроллер: UserDisplayController.list().

Frontend:
- queries.ts → useAllUsers() / allUsersQuery (5min stale, 403/404 → []).
- components/audit/UserPicker.tsx (новый, ~140 строк) — cmdk-powered
  combobox: trigger показывает username (или UUID prefix fallback),
  popover c поиском по preferredUsername/email/UUID. X сбрасывает selection.
- routes/audit.tsx — TextInput → UserPicker.

UX: раньше юзер вручную копипастил UUID из audit-row'а; теперь поиск
по имени/email типа `solov` → находит `solovyev.da`.
2026-05-25 16:48:41 +03:00
Zimin A.N. e72aa37e84 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 чистый.
2026-05-25 16:42:50 +03:00
Zimin A.N. 2548f24aff fix(admin-ui): audit — компактнее панель фильтров + popover календаря
1. DateRangePicker popover. PopoverContent имел default w-72 (288px),
   а DayPicker с numberOfMonths={2} требует ~560px. Из-за этого второй
   месяц переносился под первый, popover вылазил поверх таблицы аудита
   и кнопок «Сбросить/Применить». Override на p-3 w-auto max-w-[min(95vw,640px)]
   — два месяца теперь side-by-side, popover floats как должно.

2. Панель фильтров /audit. Было: 3-col grid с 4 input'ами + DateRange
   span'ит 2 cols на отдельной строке. Стало: на xl (≥1280px) все 5
   полей inline в одну строку (lg → 3 cols, sm → 2, mobile → 1).
   Padding p-4 space-y-3 → p-3 space-y-2. Убран border-t на action row
   — визуальный шум.

Изменения только на /audit (date-range-picker — shared компонент,
но используется только в audit-фильтрах сейчас; рендер других мест
не страдает — popover просто шире, w-auto не ломает узкий контент).
2026-05-25 15:36:32 +03:00
Zimin A.N. 7a9170d96e fix(admin-ui): убрать badge с «Справочники» — total count не actionable
Цифра у иконки словарей показывала общее число справочников (~40 на проде).
Не меняется в течение работы юзера — не actionable, только визуальный шум.

Actionable badges остаются: «Мои черновики», «На ревью», «Outbox», «Webhooks» —
там цифра реально что-то означает (надо что-то сделать или разобраться).

Заодно убрал unused dictsQuery / dictsCount + import useDictionaries из обоих
сайдбаров (desktop + mobile). TSC noUnusedLocals чистый.
2026-05-25 15:30:04 +03:00
Zimin A.N. 7b1d8834a1 fix(admin-ui): sidebar «Мои черновики» badge — считаем только actionable
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 комментарий)
2026-05-25 15:28:00 +03:00
Zimin A.N. 85177b530c fix(admin-ui): сайдбар в collapsed-mode показывает badge как corner overlay
В 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 как был.
2026-05-25 15:02:13 +03:00
Zimin A.N. b828225b28 fix(admin-ui): badge на «Мои» не обновлялся + editor wider на десктопе
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.
2026-05-25 14:59:57 +03:00
Zimin A.N. ac4f554e34 fix(admin-ui): reviews drawer — dict link, live FK, JSON syntax+diff
Три проблемы по 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 "не удалось загрузить").
2026-05-25 14:55:51 +03:00
Zimin A.N. 126b797212 fix(admin-ui): reviews — резолвить dictionaryId в имя словаря
В таблицах /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)
2026-05-25 14:51:01 +03:00
Zimin A.N. 1cb0d868c8 feat(admin-ui,cuod): array-FK picker для instrument
Три массива в 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.
2026-05-25 13:28:03 +03:00
Zimin A.N. 2a82a875c8 fix(admin-ui): хвосты — 404 на CREATE-черновиках + Radix DialogTitle warning
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 когда описания нет.
2026-05-21 16:05:09 +03:00
Zimin A.N. b48a5dd94c fix(admin-ui): «Сохранить» всегда даёт видимый ответ
Принцип: клик по «Сохранить» не должен оставаться без реакции.

Раньше 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 либо сохраняет, либо показывает «Не удалось сохранить —
проверьте поля: …». Молчания не будет.
2026-05-21 15:52:06 +03:00
Zimin A.N. dcb5612c94 fix(admin-ui): запись не сохранялась при пустом optional x-localized поле
Симптом: «Сохранить» (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-тест падает без фикса, проходит с фиксом.
2026-05-21 15:34:18 +03:00
Zimin A.N. bc426a9975 chore(admin-ui): диагностическая инструментация сохранения записи [TEMP]
ВРЕМЕННО — для поиска причины «Save ничего не делает». Откатить
после диагностики (все логи помечены [ordinis-diag]).

SchemaDrivenForm: console.warn в submit() — событие формы, RHF
onInvalid, результат AJV, ветки return, вызов onSubmit.
dictionaries.$name: логи в route.handleSubmit + createMut/updateMut
mutate + onSuccess/onError со статусом ответа.

Также добавлен постоянный регресс-тест: повторный submit после
исправления валидации вызывает onSubmit.
2026-05-21 13:28:38 +03:00
Александр Зимин 37c9ae7040 Merge branch 'fix/qa-report-sweep' into 'main'
fix(admin-ui): QA report sweep — modal/table layout, i18n, sidebar polish

See merge request 2-6/2-6-4/terravault/ordinis!239
2026-05-17 12:37:29 +00:00
Zimin A.N. d7de1eb740 feat(graph): добавлен поиск по справочникам в Graph view
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 лежит).
2026-05-17 15:36:06 +03:00
Zimin A.N. 239cecd242 fix(admin-ui): QA report sweep — modal/table layout, i18n, sidebar polish
Восемь дефектов из 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).
2026-05-17 15:23:01 +03:00
Zimin A.N. 74704dbf5b feat(notifications): Phase C — Don't Disturb (quiet hours window)
User задаёт quiet window (e.g. 22:00–08:00 Europe/Moscow) — external
channels (email + express) DROP'аются dispatcher'ом. In-app bell badge
остаётся (юзер видит при следующем визите UI). Cross-midnight aware.

## Backend

- Migration 0026: user_notification_settings table (user_id PK + quiet_hours_start/end LocalTime + tz)
- Entity UserNotificationSettings + Repository (stock CRUD)
- UserPreferencesGate extended:
  - Constructor takes settingsRepo
  - allows() checks isQuiet(userId) первым for external channels (EMAIL/EXPRESS/TELEGRAM)
  - isQuiet: load settings → if hasEffectiveQuietHours() → check current LocalTime в user's TZ
  - inWindow() helper: cross-midnight semantics (22:00–08:00 wraps midnight)
  - Invalid TZ → fallback к DEFAULT_TZ (Europe/Moscow) с WARN log
  - Test seam: setClockForTesting(Clock) для deterministic tests
- MeNotificationsSettingsController: GET/PUT /api/v1/me/notifications/settings
  - HH:mm input parsing, IANA TZ validation, enabled=false clears window
  - Auth required (JWT sub claim)

## Frontend

- NotificationSettings type в client.ts
- useMyNotificationSettings query (60s stale)
- useUpdateNotificationSettings mutation
- QuietHoursSection в /me/notifications/preferences route:
  - Switch toggle + 2 time inputs + TZ select (12 RU/UTC zones)
  - Optimistic local state synced from query
  - Save button с pending/success/error states
- i18n RU + EN

## Tests

- 9 new tests UserPreferencesGateQuietHoursTest:
  - Same-day window match (inclusive start, exclusive end)
  - Cross-midnight window match (22:00–08:00)
  - No settings row → не блокирует
  - Quiet window active → email + express dropped
  - Outside window → email allowed
  - start == end → disabled (zero-duration treated as off)
  - Reviewer pool bypasses (always ON)
  - Invalid TZ → fallback default
  - Specific pref ON + quiet ON → quiet wins (drop)
- NotificationsDispatcherTest constructor updated к new signature
- Total notifications tests: 53 → 62, все green

## Design rationale (drop vs defer)

Drop chosen за defer (queue + re-fire scheduler) потому что:
- Defer needs persistent queue + scheduled retry — high complexity
- Most notifications time-relevant (draft N hours ago less actionable)
- Bell badge показывает full feed — ничего не «теряется»
- Phase D summary digest можно build later если demand surfaces
2026-05-17 11:11:21 +03:00
Zimin A.N. e4cd7b9709 feat(ai): Phase 1 step 3-6 — LLM adapter + per-field AI suggest endpoint + UI
Approach C upgrade на templates foundation (!233). Admin может попросить AI
предложить новое поле — system prompt force'ит structural JSON Schema
fragment, admin review через accept/edit/reject. Bitemporal draft workflow
unchanged downstream.

## Backend
- `LlmAdapter` — OpenAI-compatible /v1/chat/completions client (Ollama,
  vLLM, OpenAI). Bean conditional на ordinis.ai.enabled=true.
- `AiSchemaService` — system prompt + 3 few-shot examples, JSON output
  parsing (с markdown fence stripping), validation (fieldName snake_case).
  In-memory circuit breaker: 10 fails в 60s → open 5 min.
- `AiSchemaController`:
  - GET /api/v1/ai/info → 200 (enabled) / 404 (disabled). Frontend probe.
  - POST /api/v1/ai/suggest-field → suggestion. Per-IP rate limit 30/min.
- All bean-conditional: ordinis.ai.enabled=false → controllers нет →
  endpoints 404 → frontend hides AI buttons automatically.

## Frontend
- `useAiFeatureAvailable` query — probes /ai/info, cache 5 min
- `useAiSuggestField` mutation — 30s timeout (LLM может быть slow)
- `AiFieldSuggestPanel` component — inline expandable: prompt → preview →
  accept/retry/reject. Error handling per HTTP status (503/429/422/502/404).
- DictionaryEditorDialog: AI toggle visible на schema tab когда
  aiAvailable=true. Accept wraps suggestion в mini-schema → parseSchemaJson
  (reuse existing kind-inference) → push в properties (overwrite если
  duplicate fieldName).
- i18n RU + EN

## Config
- application.yml: ordinis.ai.{enabled,endpoint,model,bearer-token,
  timeout-seconds}
- Helm writer.yaml: ORDINIS_AI_* env vars, optional secretRef для bearer
- values.yaml: ai.{enabled,endpoint,model,timeoutSeconds,bearerTokenSecretRef}
  defaults disabled

Pair с infra MR — values-staging enables AI с vortex.nstart.cloud Ollama
serving qwen2.5:7b.
2026-05-16 13:55:16 +03:00
Zimin A.N. c8dd5aad4e feat(schema): Phase 1 schema templates + Nexus DevOps runbook
## AI Schema Assist Phase 1 (steps 1-2)

Approach A core (templates only, no LLM) — admin начинает создание нового
справочника с curated skeleton вместо пустого Monaco. Approach C (per-field
LLM suggest) — Phase 2 на этом фундаменте.

### Backend
- `templates/*.template.json` в ordinis-cuod-bundle resources — 6 шаблонов
  (blank/spacecraft/ground-station/antenna/frequency-band/operator)
- `SchemaTemplateRegistry` (rest-api) — classpath scan at startup, strip
  x-template-* metadata, expose canonical JSON Schema
- `SchemaTemplatesController` — GET /api/v1/dictionaries/templates (list)
  + /{id} (detail с schemaJson)
- Cross-bundle support: scanner matches classpath*:templates/*.template.json
  — другие bundles могут shippит свои templates через те же conventions

### Frontend
- types: SchemaTemplateSummary + SchemaTemplateDetail
- queries: schemaTemplatesQuery + lazy detail query
- TemplatePicker.tsx — grid of cards (icon + name + description + tags),
  click → fetch detail → fire onPick(detail)
- Integrated в DictionaryEditorDialog (create mode, properties.length===0
  visible — после первой property picker исчезает чтобы не перезатереть)

## Nexus DevOps handoff

`docs-internal/ops/nexus-alpine-mirror.md` — runbook про APKINDEX vs file
inventory drift на v3.23 mirror. Includes:
- Verification commands
- 3 fix options (invalidate cache / scheduled refresh / self-host)
- Prometheus alert proposal
- Current workaround (node:22-alpine3.20 pin) reference

Active issue paired с CI saga !227/!229/!230 — нужен DevOps action.
2026-05-16 13:20:43 +03:00
Zimin A.N. e5a07fc391 feat(records): «Запланировано» badge per row для scheduled future versions
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».
2026-05-16 12:49:42 +03:00
Zimin A.N. cb8a229adf fix(ui): QA report P3/P4 batch — 7 cosmetic/UX bugs
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.
2026-05-16 00:09:41 +03:00
Zimin A.N. ff7e72e0fd fix(qa-report): version tag, /drafts redirect, audit filter complete, i18n cleanup
QA report v2.31.x batch:

- **VersionController.tag**: было 'none' (maven build на main без git tag) → UI
  показывал «dev · 6077d4df». Helm передаёт image.tag через env
  ORDINIS_BUILD_IMAGE_TAG, controller парсит «v2-31-2-e3c82775» → «v2.31.2».
  Helm chart change в ordinis-infra (separate MR).

- **/drafts redirect** (BUG-003): пустой 404 «Not Found» plain text → теперь
  static redirect на /my-drafts.

- **Audit action filter** (BUG-004): дропдаун содержал только 3 значения
  (CREATE/UPDATE/CLOSE), но в таблице 11 типов (DRAFT_*, DEFINITION_*).
  Frontend AuditAction enum + ACTIONS list синхронизированы с backend
  AuditLogger/SchemaDraftService/DraftService.

- **i18n cleanup** (BUG-006/007/UX-005):
  - myDrafts.description: «Все ваши submissions с pending/approved...» →
    «Все ваши черновики с историей: ожидает рассмотрения, одобрен...»
  - reviews.description: «Pending drafts от makers, ждут approve/reject» →
    «Черновики от авторов, ждут одобрения или отклонения. Общая очередь...»
  - reviews.queueTotal: «N pending drafts» → «N черновиков в очереди»
  - myDrafts.col.reviewer: «Reviewer» → «Рецензент» (RU only)
  - notifications chip labels: 'submit/approved/rejected' → 'отправлен/
    одобрен/отклонён'
  - audit.action.* expanded к 13 entries для каждого backend action
    type, с понятными лейблами «Черновик одобрен» вместо «DRAFT_APPROVE»

- **WhatsNewModal markdown** (UX-006): тексты вроде «**График доставки
  webhook'ов**» рендерились с буквальными звёздочками. Mini inline renderer
  для **bold**, *italic*, `code` — без full markdown library.
2026-05-15 22:02:29 +03:00
Zimin A.N. a740ab32bf fix(record-form): prefill validFrom=now в create mode тоже
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.
2026-05-15 21:32:41 +03:00
Александр Зимин a996b1d4e8 Merge branch 'fix/scheduled-marks-banner-prefill' into 'main'
fix(records): scheduled marks + non-empty banner + editor validFrom prefill

See merge request 2-6/2-6-4/terravault/ordinis!224
2026-05-15 18:30:30 +00:00
Zimin A.N. d0ef822b9e fix(timetravel): drop arbitrary +30d max — use real marks only
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).
2026-05-15 21:30:08 +03:00
Zimin A.N. 9199f380e1 fix(records): scheduled marks on time-travel + non-empty banner + editor validFrom prefill
Три связанных 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() — теперь юзер видит
   конкретную дату как точку отсчёта.
2026-05-15 21:28:29 +03:00
Александр Зимин 24ac9fd33a Merge branch 'fix/timetravel-modal-future-window' into 'main'
fix(timetravel): extend modal slider max до now + 30 days (paired с !220)

See merge request 2-6/2-6-4/terravault/ordinis!223
2026-05-15 18:08:45 +00:00
Zimin A.N. 1713951f76 fix(timetravel): extend modal slider max до now + 30 days (paired с !220)
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 так чтобы оба компонента
вели себя одинаково.
2026-05-15 21:08:02 +03:00
Александр Зимин fd98ce9d8e Merge branch 'feat/scheduled-records-hint' into 'main'
feat(records): scheduled records empty-state hint

See merge request 2-6/2-6-4/terravault/ordinis!222
2026-05-15 17:20:53 +00:00
Zimin A.N. 234184645f feat(records): scheduled records empty-state hint
Fixes UX bug: пользователь создаёт запись с validFrom на будущую дату
(e.g. 18.05), открывает catalog 15.05 — пустая таблица, никаких хинтов
о том что запись существует.

Backend (read-api):
- New endpoint GET /api/v1/{dict}/records/scheduled-summary
- Returns {count, nearestValidFrom} для записей с validFrom > now AND
  validTo > now в текущем scope view
- Single-aggregate JPA query (COUNT + MIN), respect scope filter same way
  as listActive — INTERNAL scheduled не учитывается для PUBLIC consumer'а

Frontend:
- New useScheduledSummary hook + ScheduledRecordsSummary type
- Empty-state в dictionaries.$name now shows: "Запланировано N, ближайшая
  на DD.MM [Перейти к этой дате]" с CTA который set'ает time-travel на
  nearestValidFrom
- i18n keys для ru/en plural forms

Связан с MR !219 (TimeTravelPicker 30-day future window) — теперь slider
позволяет дойти до даты записи, и empty-state hint позволяет узнать что
запись существует.
2026-05-15 20:17:07 +03:00
Александр Зимин 1d359beb8e Merge branch 'fix/sidebar-admin-nav-internal-only' into 'main'
fix(sidebar): hide outbox/audit/webhooks для PUBLIC users

See merge request 2-6/2-6-4/terravault/ordinis!221
2026-05-15 17:05:51 +00:00
Александр Зимин df704c28a6 fix(sidebar): hide outbox/audit/webhooks для PUBLIC users 2026-05-15 17:05:51 +00:00
Александр Зимин 4dd67af503 fix(timetravel): extend slider max до now + 30 days (или future marks) 2026-05-15 17:03:00 +00:00
Александр Зимин 4f1a02de2a Merge branch 'feat/phase-b2-per-event-prefs' into 'main'
feat(notifications): Phase B-2 — per-event-type granular preferences

See merge request 2-6/2-6-4/terravault/ordinis!219
2026-05-15 16:53:32 +00:00
Александр Зимин f04b1be1f6 feat(notifications): Phase B-2 — per-event-type granular preferences 2026-05-15 16:53:32 +00:00
Александр Зимин 4bc947b88b Merge branch 'fix/review-drawer-state-leak-between-drafts' into 'main'
fix(reviews): reset drawer state когда reviewer переключает draft

See merge request 2-6/2-6-4/terravault/ordinis!218
2026-05-15 16:40:34 +00:00
Александр Зимин 641a9a64f8 fix(reviews): reset drawer state когда reviewer переключает draft 2026-05-15 16:40:33 +00:00
Александр Зимин 597e05db4c fix(topbar): notification bell badge — fix glyph clip 2026-05-15 16:28:49 +00:00
Александр Зимин e2fc3bcbe0 feat(admin-ui): Phase B frontend — notifications preferences route + UI 2026-05-15 14:56:48 +00:00
Александр Зимин e80d31a340 fix(reviews): anonymous Mine tab — dedicated empty state с sign-in CTA 2026-05-15 13:29:11 +00:00
Александр Зимин 5c4b9c9f21 fix(admin-ui): em-dash fallback в UserCell + sidebar.collapse i18n 2026-05-15 09:02:39 +00:00
Александр Зимин 4078c60bee Merge branch 'fix/mobile-sidebar-a11y-title' into 'main'
fix(a11y): add hidden SheetTitle к MobileSidebar (Radix warning)

See merge request 2-6/2-6-4/terravault/ordinis!200
2026-05-14 22:42:00 +00:00
Александр Зимин dfae205c95 fix(a11y): add hidden SheetTitle к MobileSidebar (Radix warning) 2026-05-14 22:42:00 +00:00
Александр Зимин ac74a8fbd9 fix(sidebar): gate outbox+webhook badge polls on INTERNAL scope (no more 403 spam) 2026-05-14 22:41:28 +00:00
Александр Зимин 9e6ddd1bce fix(ui): replace raw AxiosError dumps with QueryErrorState across routes 2026-05-14 20:42:45 +00:00
Александр Зимин 35c69fd010 refactor(a11y): 44×44 touch targets + normalize accent-bg alpha 2026-05-14 17:32:10 +00:00
Александр Зимин 9050e2427e feat(notifications): TODO 7 — draft decision toast + email + bell badge 2026-05-14 14:40:35 +00:00