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 так чтобы оба компонента
вели себя одинаково.
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 позволяет узнать что
запись существует.
Acts on the two "build now" items from the design review of the approval
workflow UX (other five went to TODOS.md). Plus the TODOS.md file itself,
authored during the same review.
1. **Inline "Создать черновик с моими изменениями" CTA**
(DictionaryEditorDialog + CreateSchemaDraftModal)
Was: when a maker edited an approval-required dict, Save disabled and a
warning told them to "close the editor and click 'Создать черновик схемы'
on the dict page." Three friction steps and a hunt for a button.
Now: the warning Alert carries an action button — "Создать черновик с
моими изменениями" — that opens CreateSchemaDraftModal in-place with the
maker's in-progress schemaJson pre-loaded. They add a reason and submit.
No retyping, no hunt.
Implementation:
- CreateSchemaDraftModal accepts optional `initialProposedSchema` prop.
Seed order: edit-mode draft → caller pre-fill → live HEAD.
- DictionaryEditorDialog mounts the modal inline and triggers it from the
Alert action. Closes the editor on successful create.
2. **Diff summary chips in ReviewDrawer**
Was: two raw JSON dumps side by side. Reviewer had to eyeball-diff long
records to find the actual edit.
Now: a row of summary chips above the panes — "+N added, −M removed,
~K changed" with the field names inline. Computed shallow client-side
(shallowDiffSummary helper) — ordinis records are flat-ish at the top
level, no need for a backend diff endpoint. Skipped for CLOSE op (no
proposed) and when both sides are null.
3. **Tightened drawer header + trimmed empty-state copy**
Was: single flex-wrap line with four spans (badge + bk + maker + ts) —
visually busy. Plus a verbose empty-state description: "Когда maker
отправит schema draft на ревью, он появится здесь."
Now: two-row layout (identity row on top, provenance row below) so the
eye lands on Badge + BK first. Empty-state copy shortened to "Очередь
пуста" / "Схемы на ревью появятся здесь."
Other five gaps (Мои tab, full state-coverage pass, DESIGN.md, a11y audit,
maker notifications) recorded in TODOS.md.
После !178 (record drawer + RecordHistoryDrawer + webhook detail) ещё
осталось 6 точек где раньше рендерился UUID. Все они в "истории
словаря" и tab "События":
1. ChangelogDiffModal:57 — header "автор: <name>" в diff modal.
Было: `{data.publishedBy.name}` (тип ChangelogPublisher.name —
docstring буквально говорит "Backend пока эхает sub").
Стало: <UserCell uuid={data.publishedBy.sub} />.
2. TimeTravelModal:686 — список изменений, имя автора рядом с датой.
3. TimeTravelModal:898 — RIGHT panel "История этой версии", dt/dd
pair с автором.
4. TimeTravelModal ComparePanel:414 — "· {author}" suffix,
author там DictionaryDetail.updatedBy (тоже UUID).
5. dictionaries.$name.tsx:2263 (events tab, EventsTabContent row) —
`{r.userId ?? 'anonymous'}` raw. Сменил на UserCell для не-null
userId, anonymous-fallback оставил без UserCell (нечего lookup'ать).
6. dictionaries.$name.tsx:2399 (history tab, HistoryTabContent entry)
— `{entry.publishedBy.name}` raw.
Всё через тот же useUserDisplay hook что и в !177 — backend
endpoint /admin/users/{sub}/display + JwtUserCaptureFilter cache.
Если sub нет в cache, UserCell fallback'ит на shortened UUID
(текущее поведение из !177).
ChangelogPublisher type comment в src/api/client.ts уже намекал что
.name это placeholder — useUserDisplay теперь реально резолвит.
MR !177 added /admin/users/{sub}/display + UserCell + useUserDisplay,
but only some renderers were migrated. Three places still rendered
raw UUID strings:
1. reviews.tsx:466 — record draft drawer ("Согласование draft" header)
showed `{draft.makerId}` raw. Tables on the same page already use
<UserCell uuid={d.makerId} />. Verified via prod staging:
soloviev.da approved a record draft today and the drawer header
showed the maker as "77ec2cc8-98e3-4d70-8037-94038fcbde67" instead
of the username.
2. RecordHistoryDrawer.tsx:110 — version history `updatedBy` raw.
3. webhooks.$id.tsx:235 — webhook subscription `createdBy` raw.
All three switched to <UserCell uuid={...} />, matching the existing
pattern. Added the import where missing. Wrapped each in
inline-flex so the UserCell sits next to the label cleanly.
No backend / API changes — same /admin/users/{sub}/display endpoint
populated by JwtUserCaptureFilter (MR !177).
Два бага в /webhooks (user report):
1. **500 при выборе scope в форме**
POST /admin/webhooks/subscriptions с scopeFilter:["PUBLIC"] возвращал 500.
Root cause: WebhookSubscription.scopeFilter был List<DataScope> с
@JdbcTypeCode(SqlTypes.ARRAY). Hibernate сериализовал enum в TEXT[] как
ordinal (е.g. {0} для PUBLIC), а DB CHECK constraint
chk_webhook_scope_filter ожидает строковые имена. Insert падал
DataIntegrityViolationException → 500.
Fix: field теперь List<String> (raw enum names). Getter/setter
конвертируют в/из DataScope — API surface (DTO, service signatures,
WebhookDispatcher) не меняется.
2. **Нет toggle active/inactive в UI**
useUpdateWebhook hook был, но не использовался в UI. Status показывался
только read-only badge'ом. Невозможно деактивировать webhook через UI.
Fix: добавлен toggle button рядом с Status badge на /webhooks/:id.
Sends PUT с inverted active flag (backend treats PUT как full replace,
поэтому пересобираем full payload).
i18n: 'webhooks.action.activate' / 'deactivate' default labels (RU).
Phase 1d enhancement: 'Согласования' пункт сайдбара скрыт если у user'а
нет approver/reviewer ролей. Раньше показывался любому authenticated —
оператор без reviewer прав видел queue, кликал и получал пустую
страницу (или 403 если бы пробовал approve).
Visibility rules:
- 'Мои черновики' — auth (maker может быть любой)
- 'Согласования' — auth + (ordinis:schema:reviewer OR ordinis:record:reviewer
OR ordinis:admin super-role) — иначе hidden
- 'Outbox' / 'Webhooks' / 'Аудит' — auth (admin-y но backend не gated
отдельной ролью)
Новый NavItem.reviewerOnly flag — фильтр в visibleSections учитывает
оба flag'а (authRequired + reviewerOnly).
Применено и к desktop Sidebar и к MobileSidebar (DRY refactor — отдельный
task).
User report: token has roles в resource_access.ordinis.roles (client-scoped),
backend WorkflowRoles читал hardcoded realm_access.roles → forbidden_role
для всех reviewer actions.
Backend:
- WorkflowRoles: depends OrdinisAuthProperties → reads via rolesClaim path
(же что ScopeContext). Default realm_access.roles, на staging/prod env
var ORDINIS_AUTH_ROLES_CLAIM=resource_access.ordinis.roles (уже wired
в helm helper).
- Nested claim path resolver — mirror ScopeContext.readClaimByPath
- New super-role ADMIN ('ordinis:admin') — holder автоматически проходит
любой workflow check без явного назначения отдельных ролей. Convenience
для small teams.
Frontend (usePermissions.ts):
- tokenRoles() читает оба claim path (realm_access.roles +
resource_access.ordinis.roles) и объединяет — UI gate работает
независимо от backend config
- hasRole() recognizes 'ordinis:admin' как super-role override
- ROLE_ADMIN exported
Docs:
- docs/operator/rbac.md: добавлен ordinis:admin row, JWT claim path note,
Admin profile updated (super-role vs composite explanation)