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.
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 позволяет узнать что
запись существует.
User feedback (screenshot): 'Approve' остался английским в RU локали +
'Отозвать' улетал на отдельную строку справа из-за ml-auto.
Fix:
- i18n.ts: 'workflow.schemaDraft.actions.approve' default 'Approve' →
'Одобрить'. EN остался 'Approve'.
- SchemaDraftDrawer.tsx: убрал ml-auto на withdraw button. Раньше это
force pushed его в конец строки → flex-wrap отправлял на новую строку
правым краем. Сейчас withdraw сидит сразу после reject в той же
flow-row, с danger color для визуальной distinction. На узком экране
всё ещё wrap'нется естественно (без 'плавающего' alignment).
Sidebar:
- Outbox: бэйдж = outbox.pending (события queued, не отосланы). DLQ
отдельно — alarming но не actionable из бэйджа, видно на /outbox.
- Webhooks: бэйдж = count активных subscriptions (w.active === true).
User feedback: 'вебхуки активные есть а цифры с бэйджем нет'.
Audit log column header:
- 'Scope' → 'Доступ актора' (ru) / 'Actor scope' (en)
- Backend audit_log хранит userScope (claim из JWT актора), НЕ scope
изменённого ресурса. User confusion: 'правки в public были а тут
RESTRICTED' — потому что у пользователя scope-привилегия RESTRICTED.
Уточнённая label убирает путаницу.
- TODO: backend feature — добавить dictionaryScope в AuditEntry чтобы
показать обе ('Актор: RESTRICTED → Ресурс: PUBLIC' — двусторонний
audit trail для compliance).
User explicitly: "ThemeSwitch не трогай". Реверт my 2-button Earth|Dark
переделку обратно на 3-icon Sun/Moon/Monitor segmented control (по
original 3bfa7ca).
Также:
- schema.action.create RU: "Создать справочник" → "Создать"
- schema.action.create EN: "Create dictionary" → "Create"
(per prototype catalog button — короче чтобы не растягивать toolbar)
Phase 4 last optional UX из design doc — bulk operations:
- Multi-select column в reviewer queue (Checkbox per row + select-all
header). Set<draftId> selection state, persisted между refetch'ами.
- "Approve выбранные" — confirm dialog → parallel approveDraft mutations
через Promise.all. Каждая mutation independent: если одна fail
(409 self_approve_forbidden / draft_not_pending race), остальные
продолжают.
- "Reject выбранные" — Modal с TextArea для shared reason (required,
matches backend reject_reason_required validation). Reason одинаковый
для всех — bulk обычно "duplicate registration" / "wrong scope" / etc.
- Bulk result Alert: approved count / rejected count / failed list с
per-draft reason'ами (err code из 409). User видит точно что прошло
и что нет.
- Auto-clear selection после bulk operation. Queue refetch'ит через
refetchInterval=30s, плюс mutation onSuccess invalidate'ит ['drafts'].
i18n RU/EN: 22 keys (selectAll/selectRow/selectedCount/approveSelected/
rejectSelected/clear/confirmApprove/rejectModal*/confirmReject/cancel/
resultTitle/approvedCount/rejectedCount/failedCount). Pluralization
через i18next plurals.
Why ship without soak data:
- Implementation не зависит от queue depth — это просто loop'ит над
выбранными drafts.
- Reviewer на любом dict с queue >5 уже выгадывает время.
- Если queue staying small после soak — feature не bothering нас (UI
toolbar показывается только когда anySelected).
Tests: vitest 89/89, tsc clean, prod build clean.
Approval Workflow v2 Phase 4 — UX:
Backend:
- DraftController.myDrafts: GET /api/v1/drafts/me?page&size — все статусы
(PENDING/APPROVED/REJECTED/WITHDRAWN) текущего authenticated user, sorted
submitted_at DESC. Resolve maker_id из JWT subject (fallback "anonymous"
если auth off).
- Reuses existing DraftService.listByMaker (Phase 1 method, был не exposed).
Frontend:
- routes/my-drafts.tsx: новый маршрут с табличным view.
Колонки: businessKey / operation / status / submittedAt / reviewedAt /
reviewerId / comment / actions. Status badge tinted (warning/success/
error/neutral). PENDING строки имеют Withdraw кнопку (calls existing
useWithdrawDraft с confirm).
- api/queries.ts: useMyDrafts (refetch 30s — maker увидит когда reviewer
decide).
- api/mutations.ts: useWithdrawDraft теперь invalidate'ит ['drafts'] —
/me + by-dict обе видят изменение.
- routes/__root.tsx: nav link "Мои черновики" / "My drafts" (после Reviews).
- i18n RU/EN: 18 keys (nav.myDrafts, myDrafts.* col/action/status).
Pluralization для total через i18next plurals.
Why это нужно:
- До этого maker submit'нул draft и пропадал — единственный feedback был
badge "На review" в records list (только для текущего dict). Здесь — все
его submissions с history + withdraw для PENDING.
- Reviewer pool (D3=A) маленький, /reviews queue только для них. Maker
без role'а не видит queue, но должен tracking своих submissions.
Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.
Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.
Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
per-dict cap. SQL injection защищён PreparedStatement param + extension
whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
Scope filtering through ScopeContext — каждый caller видит только свои
scope levels. Results grouped per dict с display name + count + items.
Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.
Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
index server (Elastic / Meili).
Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.