Commit Graph

53 Commits

Author SHA1 Message Date
Zimin A.N. 3e4d78906d feat(records): accent businessKey + remove redundant Export CSV from bulk bar
Per user feedback:
- "поле Бизнес-ключ выделен цветом" — businessKey column TableCell теперь
  `text-mono text-accent` (было plain text-mono). Matches prototype 03 где
  RES-P-1, KAN-V-3, SNT-1A показаны в accent orange — primary identifier
  для каждой row.
- "Экспорт CSV в bulk bar лишний как повтор Экспорт кнопки в InfoPanel" —
  убрал Export button из BulkSelectionToolbar. ExportModal pre-picks
  scope=selected когда selection.size > 0, поэтому workflow:
  select rows → click "↓ Экспорт..." в InfoPanel → modal с pre-selected
  scope. Бэк endpoint тот же, UX cleaner — один путь к export'у.
- Removed handleBulkExport callback + DownloadSimpleIcon import (теперь
  unused).
2026-05-11 20:14:43 +03:00
Zimin A.N. ce1f40c5d1 feat(editor): wrap InfoPanel + main content in bordered panels per prototype
Per user feedback: "словари в своих панелях то как у нас было ранее".

Per redesign prototype editor view (Ordinis(4) compact.html):
- InfoPanel: was floating sidebar aside без borders → теперь
  `rounded-lg border border-line bg-surface p-4` — visible bordered card
  containing PUBLIC chip + meta + Связи + actions.
- Main editor area: tabs + toolbar + tab content (records/relations/
  fields/json/events/history) обёрнуты в `rounded-lg border border-line
  bg-surface overflow-hidden` panel.
- WorkflowBanner и AOI banner остаются outside panels (status chrome).

Visual containment отделяет editor chrome от page background, делает
очевидным где info ends / table starts. Matches prototype Screen 03-08.
2026-05-11 20:11:04 +03:00
Zimin A.N. a27e9ec901 fix(table): white row bg + stronger hover per user feedback
User: "строки таблиц белые / ховер на них более чёткий а то еле видно".

Прошлый hover был bg-surface-2/40 (40% opacity на cream/yellow page bg) —
почти неотличим от default. Под page bg #faf9f5 + 40% opacity surface-2
тёмное пятно еле проступало. На long тяжело отследить какая row под cursor.

Изменения:
- DictRow (catalog table): убран subtle scope-color bg tint (был
  warn-bg/30, pink-bg/30 — еле виден на page bg). Row всегда `bg-surface`
  (white). Scope visually conveyed через 3px left-stripe + colored SCOPE
  chip. Hover full strength `bg-surface-2` (no /40 opacity).
- FieldsTabContent + EventsTabContent rows: same change — bg-surface +
  hover:bg-surface-2.
- Shared TableRow component (ui/components/table.tsx): bg-surface +
  hover:bg-surface-2 — applies к records table, audit, и любым другим
  TableRow callsites.
2026-05-11 20:00:40 +03:00
Zimin A.N. cbe21dbc52 feat(editor): Fields / Events / History tabs match redesign prototype
Per redesign/screens/{05-fields,07-events,08-history}.png:

Fields tab (Поля):
- # numbered col (01, 02, ...) — mono mute, tabular-nums
- ПОЛЕ — bold accent (was plain mono)
- ТИП — inline FK render: `fk → target_dict`, array as `string[]`,
  date/date-time format names, enum, native types
- REQUIRED / UNIQUE / I18N / INDEX — checkmark (green) or — dash
- INDEX heuristic: x-unique || x-references → indexed (backend doesn't
  expose explicit flag yet)
- ОПИСАНИЕ truncate с max-w-[320px]

Events tab (События):
- Filter chips above table: все N / webhooks / publish / review / edit /
  export (только показываем те которые есть, кроме «все»)
- "Полный лог →" link справа в chips row
- Table cols: Время (5 sec precision) / Тип (colored chip) / Статус
  (● dot + ok/fail) / Куда·Кто (user) / Сообщение (businessKey · action)
- Type chips colored: webhook=accent, publish=green, review=warn,
  export=surface-2, edit=accent-bg/50

History tab (История):
- HEAD row: ● filled dot + v{version} + HEAD chip (dark) + change summary
  (+N ~0 −0 placeholder until backend changelog endpoint появится)
- Description + updatedAt date
- Backend-pending notice card: "/api/v1/dictionaries/{name}/changelog pending"

Plus: + import { cn } from '@/lib/utils' для новых tabs
2026-05-11 19:20:57 +03:00
Zimin A.N. 16da694d8c feat(record): conflict diff modal на 409 save (handoff line 444)
Когда PUT /records/{key} возвращает 409 (optimistic-lock fail — другой
пользователь сохранил тот же record между нашим load и submit'ом),
открываем ConflictDiffModal.

Component:
- Header: 'Конфликт сохранения · {DICT}' + business key
- Diff body: refetch server state via useRecordRaw, иди по union ключей
  local + server, оставь только отличающиеся. Per-row: field name (mono)
  + ваше значение (accent-bg) + серверное (surface-2)
- Footer 3 actions:
  - Отмена: close, оставить editor open
  - Загрузить серверное: discard local, refetch records, close editor
  - Перезаписать: re-submit current payload (если опять 409 → новый diff)
- Backend-friendly: не требует server diff endpoint, client считает diff
  client-side через JSON.stringify equality

Route wiring:
- onError handler distinguishes generic 409 (open diff modal) от
  специальных кодов: x_references_blocked_by_dependents,
  x_references_cascade_required, draft_required — те идут в свои dialogs
- Conflict state ({payload, businessKey}) capture'ит local data чтобы
  показать в diff даже после reset формы
2026-05-11 19:13:30 +03:00
Zimin A.N. 5a2220d779 feat(editor): ExportModal + TimeTravelModal + InfoPanel merge + fixes
UI redesign matching prototype (Ordinis (3) dump):
- ExportModal: format (CSV/JSON/Excel/SQL) + scope (all/filtered/selected) +
  column toggles + encoding/delimiter + preview filename per redesign line
  1059-1092. CSV uses backend mutation, JSON client-side from filtered rows,
  Excel/SQL disabled (backend pending).
- TimeTravelModal: full-page compare СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ + quick
  presets (сейчас/неделя/месяц/год/при создании) + step toggle версия/день
  + slider + tabs (Что изменилось / Записи на момент / Структура и поля) +
  "Открыть как readonly" CTA. Replaces inline TimeTravelPicker.
- InfoPanel ← используют merge: rich rows с field path + active records
  count + onClose policy chip (BLOCK/WARN/CASCADE). Inline
  DictionaryDependentsPanel above records table removed (one source of truth).
- Sidebar logo: match redesign/ui-kit.html .brand SVG (outer ring + inner
  faded ring + orbit dot offset right cx=22.5). ORDINIS Tektur tracking 0.22em
  + MDM smaller mute 0.32em.
- Bg neutralized: #fbf8ee Earth cream → #faf9f5 warm neutral default
  (per user preference, Earth cream still described in comment как opt-in).
- WorkflowBanner: Case 1 Live (approvalRequired=false) returns null —
  permanent "Опубликовано" banner = noise, status already visible в
  InfoPanel scope/version/updatedAt. Banner показывается только когда
  есть actionable state.

Critical bug fixes:
- fix(CascadeConfirmDialog): infinite render loop "Maximum update depth
  exceeded". useEffect had cascadeMut (TanStack Query mutation wrapper) в
  deps — wrapper identity changes every render → effect fires → reset() →
  store update → render → loop. Extract stable `reset` reference перед
  useEffect. Симптомы у user: создание/редактирование записи не работали.
- fix(semantic-release): add missing peer dep
  conventional-changelog-conventionalcommits (9.3.1). semantic-release 22+
  больше не bundles preset. Без неё release job падал с
  "Cannot find module 'conventional-changelog-conventionalcommits'".
2026-05-11 19:01:57 +03:00
Zimin A.N. e15a3916bf feat(editor): + Экспорт button в InfoPanel per redesign dark theme screenshot
User dropped /Users/zimin/Downloads/Ordinis (3) — full project dump.
Dark theme editor screenshot (uploads/draw-4b771386...png) показывает
INFO PANEL c двумя buttons:
  - ↓ Экспорт…
  - ⟲ Time-travel…

У нас был Time-travel + AOI — добавил Экспорт как third button.

Implementation:
  - EditorInfoPanel.actions.onExport + exportPending props
  - DownloadSimpleIcon кнопка 'Экспорт…'
  - Wired к useBulkExportRecords mutation:
    * Selection не empty -> export selected
    * Selection empty -> export all filtered records
  - disabled когда mutation pending, label 'Экспорт…'

Project dump reviewed:
  - design_handoff_dictionary_catalog — старый handoff, всё уже реализовано
    (List view MR !62, Hub MR earlier, Graph earlier)
  - patches/ — @nstart/ui patches, moot (мы dropped @nstart/ui MR !52)
  - redesign/ — уже синхронизированы colors MR !71, layout MR !69
  - uploads/ — user-provided screenshots, main delta = Export button
2026-05-11 18:03:52 +03:00
Zimin A.N. ec99010697 feat(editor): match redesign prototype layout — TopBar title + tab counts + InfoPanel actions
Per /Users/zimin/Downloads/redesign/screens/03-editor-records.png редизайн
prototype editor layout отличается от current MVP:

1. TopBar absorbs editor title + version (was в PageHeader)
2. PageHeader удалён — title и breadcrumb теперь только в TopBar
3. Tab labels получили counts (Записи 127, Связи 6, Поля 12)
4. Action buttons распределены per role:
   - Time-travel + AOI → InfoPanel bottom (close к data context)
   - Schema edit + +Запись → tab toolbar right (close к tab actions)

Changes:

src/components/layout/TopBar.tsx
  - Breadcrumb получил subtitle slot — mono '<name> · v<version>'
  - useBreadcrumb fetches dict detail для editor route '/dictionaries/$name'
  - Render: '← Справочники / Космические аппараты satellites · v1.0.0'

src/api/queries.ts
  - useDictionaryDetail принимает name: string | undefined + enabled flag
  - Guard для editor breadcrumb когда route не matches dict

src/routes/dictionaries.$name.tsx
  - Removed <PageHeader> — title/breadcrumb теперь в TopBar
  - EditorInfoPanel получает actions prop:
    onTimeTravel + timeTravelActive + onAoi + aoiActive
  - EditorTabBar получает counts (per-tab badge) + actions slot
    (right-aligned Schema + +Запись buttons)
  - extractOutgoingFkCount helper для relations count

src/components/editor/EditorInfoPanel.tsx
  - actions prop с Time-travel + AOI buttons
  - Rendered в panel выше Relations section, между metadata и FK list
  - aria-pressed когда active (time-travel или AOI с filter)

src/components/editor/EditorTabBar (inline in route)
  - counts prop: Partial<Record<EditorTab, number>>
  - actions prop: ReactNode для right-aligned cluster
  - inline count badge — text-mono text-mute next to label

Tests: 116 pass. TS strict clean.
2026-05-11 17:45:06 +03:00
Zimin A.N. 889207f629 feat: wire drawer toolbar filters + records table responsive hiding
Closing 2 handoff gaps:

1) DRAWER TOOLBAR FILTERS (handoff Screen 3 line 279)
SchemaDrivenForm получил три new optional props:
  - searchFilter (string) — case-insensitive substring против field key + label
  - onlyFilled (boolean) — hide fields с empty data[key]
    (null/undefined/''/false/0/empty-array/empty-object)
  - onCounterChange (visible, total) — surface field counter parent'у

Filter применяется поверх bucketing — все три buckets (identity/description/
extra) фильтруются. UI рендерит filteredBuckets.X.map(...) вместо raw bucket'ов.

В sections layout (drawer use case) если section после filter empty —
sticky header не рендерится. Tabs layout backward-compat (filter применяется
но visually invisible если user в другом tab'е).

dictionaries.$name.tsx callsite:
  - State: drawerSearch, drawerOnlyFilled, drawerCount {visible, total}
  - Reset on drawer close
  - RecordDrawer.toolbar prop wired только в edit mode (create — все поля
    empty, 'только заполненные' даст 0/0)
  - SchemaDrivenForm receives все три filter props

Result: user может typing 'mass' в drawer search → видит только mass_kg /
data_mass / etc.; toggle 'только заполненные' → hide unused optional fields.

2) RECORDS TABLE RESPONSIVE COLUMN HIDING (handoff line 466)
Records table в dictionaries.$name.tsx применяет hideBelow API из MR !59:
  - businessKey: всегда visible
  - name/code: hideBelow='md' (768px)
  - extra schema columns: hideBelow='lg' (1024px)
  - scope badge: hideBelow='sm' (640px)
  - validFrom: hideBelow='lg'
  - actions: всегда visible

На mobile 375px только businessKey + actions visible — chrome остаётся
usable. Tablet 768+ adds name/scope. Desktop 1024+ adds schema columns.

Tests: 116 pass.
2026-05-11 17:10:26 +03:00
Zimin A.N. 155c70e23c feat(drawer): all-sections layout с chips strip per handoff prototype
Handoff Screen 3 RecordDrawer показывает sections подряд с chips strip
для quick jump, не tabs (currently SchemaDrivenForm has 3 tabs).

SchemaDrivenForm:
  - New optional prop layout: 'tabs' (default, legacy) | 'sections'
  - В sections mode:
    * Section chips strip на верху (sticky top-0) вместо Tabs
    * Chip = bucket name + field count в Mono
    * Click chip → scrollIntoView к section
    * Все 3 buckets рендерятся подряд (visible одновременно)
    * Каждая section получает sticky h3 cap header + scroll-mt-12
    * z-indexed sticky: chips strip = 10, section headers = 5
  - В tabs mode: backward-compat без изменений

RecordDrawer callsite (dictionaries.$name.tsx):
  - Передаёт layout='sections' в SchemaDrivenForm (create + edit)
  - Существующий tabs API остаётся для test'ов которые используют default

Layout matches handoff Screen 3 prototype:
  [chips strip sticky]
  [section: Основное]
   field grid 2-col
  [section: Описание (i18n)]
   localized fields
  [section: Дополнительно]
   optional fields grid

Future Phase B refactor: backend x-section schema metadata (open question
#5) разблокирует per-property sections (вместо bucketing). Текущий
fallback bucketing (identity/description/extra) достаточен для MVP.

Tests: 116 pass (test использует default 'tabs' layout, не разломан).
2026-05-11 16:37:38 +03:00
Zimin A.N. a57cf520a4 feat(editor): left info panel + 3-col layout per handoff prototype
Handoff prototype design/compact.html показывает editor как 3-col layout:
[Sidebar 220 (global nav)] [InfoPanel 220 (dict metadata)] [main content].
Текущая implementation была 2-col (nav + everything).

NEW src/components/editor/EditorInfoPanel.tsx:
  - Scope badge (PUBLIC/INTERNAL/RESTRICTED) — top
  - ОПИСАНИЕ section — caption + dict.description body
  - Metadata 2x2 grid: Bundle / Версия / Записей / Локали
    (mono text-ink-2, cap labels)
  - СВЯЗИ section с двумя группами:
    → ссылается  outgoing FK list (name links к /dictionaries/$name)
    ← используют incoming refBy list (deduplicated sourceDict names)
  - sticky lg:top-2 self-start чтобы panel оставалась видна при scroll
  - lg:max-h-[calc(100vh-7rem)] overflow-y-auto если metadata высокий

src/routes/dictionaries.$name.tsx:
  - Обернул содержимое (banner + tabs + tab content) в:
    <div className='grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6'>
      <EditorInfoPanel ... />
      <div>{banner + tabs + content}</div>
    </div>
  - На <lg переключается в stacked (info above content) — single column
    благодаря grid-cols-1.

PageHeader остаётся выше grid (full-width breadcrumb + title + actions).
Action buttons (AOI / Time-travel / Schema edit / Создать) живут в
PageHeader.actions slot — handoff prototype показывает их в info panel,
но рядом с title лучше discoverable. Может уйти в info panel в future
refactor.

Tests: 116 pass. TS strict clean.
2026-05-11 16:33:40 +03:00
Zimin A.N. a09c4113c1 feat(editor): keyboard shortcuts j/k/Enter/n per handoff Screen 2
Handoff line 438: 'Pressing j/k moves selected row; Enter opens drawer;
Esc closes; n opens new-record drawer; / focuses search.'

Implementation:
  - rowCursor state — index в visibleRecords (-1 = no focus)
  - j или ArrowDown → cursor++ (clamped)
  - k или ArrowUp → cursor-- (clamped)
  - Enter → setEdit({kind:'edit', record}) когда cursor valid
  - n → setEdit({kind:'create'}) когда canMutate
  - Esc — handled Radix Dialog natively через RecordDrawer
  - / — handled TopBar (⌘K equivalent, focuses global search input)

Guards:
  - Skip когда target inside input/textarea/contentEditable (no conflict с typing)
  - Skip когда drawer/modal open (edit.kind !== 'closed' — focus в portal)
  - Cursor reset на page/filter change (stale index безопасности)

Visual: active row получает bg-accent-bg/40 outline accent для clarity.
2026-05-11 16:00:42 +03:00
Zimin A.N. 0acba4c073 feat(handoff): WorkflowBanner + table a11y + responsive + toast styling
Closing handoff gaps выявленных при полном audit'е README.md vs current
implementation. См. screen-by-screen mapping ниже.

== Screen 2 — Editor: WorkflowBanner (NEW) ==
Required by handoff (line 238-242) но не существовал. Inline status row выше
tab bar — live/review/info variants. Backend не имеет explicit dict-level
workflow state (draft → review → live transition), используем proxy:
  - approvalRequired=false      → 'Live' (green-bg + green left edge)
  - approvalRequired=true + 0   → 'Approval enabled' (accent-bg + accent edge)
  - approvalRequired=true + N   → 'На ревью N' (warn-bg + warn edge), link к /reviews

Backend extension future: добавить explicit workflowState к DictionaryDetail
+ buttons для transitions (request-review / approve / publish). Сейчас banner
дает минимум — visual workflow signal без UI mutation buttons.

== Table accessibility per handoff line 476 ==
TableHeaderCell:
  - scope='col' (a11y screen reader requirement)
  - aria-sort='ascending|descending|none' когда sortable prop true
  - New sortable + sortDirection props (API ready, не используется пока)

== 560px responsive per handoff line 466 ==
TableHeaderCell + TableCell получили optional hideBelow='sm|md|lg' prop:
  hideBelow='sm' → hidden <640px (table-cell ≥sm)
  hideBelow='md' → hidden <768px
  hideBelow='lg' → hidden <1024px

Callsites могут пометить secondary columns (created_by, updated_at, etc.)
hideBelow='md' для clean mobile experience. Текущие routes ещё не используют
prop — API ready для incremental adoption.

== Toast styling per handoff line 302-305 ==
Sonner toast — раньше surface bg + ink text. Handoff:
  - Navy bg + on-accent text (warm cream-orange feeling)
  - 4s auto-dismiss
  - Variant accent через left border 4px:
    success → green / error → pink / info → accent / warning → warn
  - cap-style title (но используем text-body для нативности, не overkill)
  - 13px font-sans

richColors disabled — наши tokens вместо sonner defaults.

Files:
  - NEW src/components/editor/WorkflowBanner.tsx
  - src/routes/dictionaries.$name.tsx (wire banner above tab bar)
  - src/ui/components/table.tsx (a11y + hideBelow API)
  - src/ui/components/toaster.tsx (navy variant + variant borders)
  - src/ui/index.ts (TableCellProps export)

Tests: 116 pass. TS strict clean.
2026-05-11 15:58:12 +03:00
Zimin A.N. 787cea9db1 fix(table+layout): TableHead alias to THEAD wrapper + kill horizontal scroll
CRITICAL bugs reported by user — table выглядела кривой, контент уезжал
за экран с горизонтальным скроллом.

Bug 1 — invalid HTML в Table:
  Callsites используют <TableHead><TableRow><TableHeaderCell>... ожидая
  нstart-style THEAD wrapper. Но мой alias делал TableHead = TableHeaderCell
  (TH cell). Получалось <th><tr><th>...</th></tr></th> — invalid nested,
  browser parser hoist'ил TableRow наружу, создавая phantom 509px column
  слева от данных. Колонки выглядели misaligned, content смещён вправо.

  Fix: TableHead → THEAD wrapper (alias TableHeader). TableHeaderCell
  остаётся отдельным TH cell. Source semantically correct, browser parses
  валидно, phantom column исчез.

Bug 2 — horizontal overflow:
  Scope strip border на верху detail-страницы использовал
  '-mt-6 -mx-4 sm:-mx-6 lg:-mx-8'. На lg viewport main padding только
  sm:px-6 (24px), strip negative margins -mx-8 (32px) → 8px overflow each
  side → body.scrollWidth > viewport → horizontal scrollbar.

  Fix:
  - Strip negative margins только до -mx-6 (соответствует main padding)
  - main получил overflow-x-clip + max-w-7xl wrapper получил min-w-0
    (защита от любых future overflow children)

Verified в preview viewport 1440 + 375: body.scrollWidth === viewport,
no h-scroll.
2026-05-11 15:49:27 +03:00
Zimin A.N. f0392af0e3 feat(editor): Stage 3.4 polish — Events embedded + JSON syntax highlight
Per handoff Stage 3.4 polish list (Records/Relations/Fields/JSON/Events/
History tabs).

Events tab — раньше был redirect к /audit?dict=name. Теперь embedded:
- useAudit({dictionaryName, size: 20}) → last 20 entries
- 4-col compact table: time / action / businessKey / user
- Empty state когда нет событий
- Bottom link → /audit с filter для full diff view

JSON tab — раньше был plain <pre>. Теперь:
- Regex-based syntax highlight (keys=accent, strings=green, numbers=warn,
  booleans=pink, null=mute italic)
- Copy-to-clipboard button с visual feedback
- Bundle cost: 0 (нет Monaco). Если потребуется EDIT — Monaco lazy
  через React.lazy в Stage 3.x followup.

Fields tab — пока read-only table остаётся. Inline edit требует backend
PATCH /dictionaries/{name}/schema endpoint; deferred.

History tab — placeholder remains. Требует backend changelog endpoint;
deferred.

TS strict + 116 tests pass.
2026-05-11 15:28:29 +03:00
Zimin A.N. 5007e0f4eb feat(ui): drop @nstart/ui dependency (Stage 3.9)
Все font cascade bugs из последних MR были вызваны @nstart/ui pollution
(Tektur inheritance от Card parent'ов, Onest font в Button, font-primary
class overrides). Решили обрезать раньше plan'а.

What changed:

New primitives (8 файлов в src/ui/components/):
  - panel.tsx           — container с border/surface
  - field.tsx           — FieldLabel/Hint/Error typography helpers
  - text-input.tsx      — Input + label/hint/error composite
  - table.tsx           — Table/TableHeader/Body/Row/HeaderCell/Cell/Empty
  - date-picker.tsx     — native <input type=date> wrapper с label/hint/error
  - multi-select.tsx    — Radix Popover + inline checkboxes
  - single-select.tsx   — native <select> с label/hint/error chrome
  - language-switch.tsx — segmented control в Tektur uppercase

Compatibility shims (existing components extended):
  - checkbox.tsx        — + label/description/error/onChange (nstart-compat
                          synthetic event), wrap в <label> когда есть chrome
  - modal.tsx           — + panelClassName / bodyClassName props
  - form-actions.tsx    — + align prop (start/end/between)
  - tabs-simple.tsx     — TabItem.count alias for .trailing (nstart-compat)
  - table TableHead     — alias TableHeaderCell с align prop

Barrel rewrite (src/ui/index.ts):
  - Удалён 'export * from @nstart/ui'
  - Explicit exports для всех 45+ нужных символов
  - SelectOption / MultiSelectOption / LanguageOption теперь {id, label}
    с optional 'value' alias — nstart-compat без массового codemod call-sites

styles.css cleanup:
  - Removed @import @nstart/ui/styles/{fonts,theme}.css
  - Removed @source nstart/dist directive (Tailwind v4 scan)
  - Removed --text-sm: 13px override (был костыль для @nstart/ui dist)
  - Legacy color tokens (--color-carbon/ultramarain/orbit/regolith/asteroid)
    sсодержали fallback mappings — оставлены, но callsites больше не используют

Codemod: 9 файлов конвертированы legacy colors → handoff tokens
  carbon → ink, ultramarain → accent, orbit → warn, regolith → line, asteroid → mute

main.tsx:
  - NStartUiProvider → TooltipProvider (Radix)
  - DatePickerProvider убран (наш DatePicker native, no provider needed)

routes/dictionaries.$name.tsx:
  - Removed useRef-based <input>.indeterminate hack
  - Radix Checkbox принимает checked='indeterminate' declaratively

package.json: -@nstart/ui (was 0.1.3)

Bundle impact:
  before: vendor-nstart-ui chunk = 1228 KB
  after:  no nstart chunk, всё в vendor-misc (+~70 KB Radix wrappers)
  net savings: ~1.15 MB minified, ~440 KB gzip

Tests: 116 pass (1 fixed: tab role from 'button' → 'tab', Radix semantically correct).
TS strict: clean.
Browser-verified (preview): 7 utilities computed correctly, nstart bundle absent.
2026-05-11 15:17:37 +03:00
Zimin A.N. 750395da09 feat(record): RecordDrawer slide-over per Stage 3.5 handoff (Phase A)
Заменяет Modal-based record create/edit на right slide-over drawer per
design_handoff_ordinis_mdm/README.md Screen 3.

What's в Phase A (drawer shell + sticky chrome):

- src/components/record/RecordDrawer.tsx (NEW)
  - Two widths: 520px default / 880px wide, toggle через ‹/› button.
    Persists в localStorage 'ord-drawer-wide'.
  - Sticky header: caption (cap style) + recordId mono + width-toggle +
    history button (edit mode) + ×.
  - Sticky footer: [Удалить] (danger, edit mode) · 'не сохранено · N полей'
    caption · [Отмена] · [Сохранить] (drives form submit через form attr).
  - Built на shadcn Sheet primitive + Radix Dialog. Override sheetVariants
    inline для transition-able max-width (variant size не toggle'ится
    smoothly без animate prop).
  - aria-labelledby wires DialogTitle к содержимому header.
  - Mobile <880px: w-full fullscreen (per handoff responsive table).

- src/components/form/SchemaDrivenForm.tsx
  - New optional props: formId, onDirtyChange.
  - Когда formId передан — внутренний FormActions footer прячется (drawer
    footer driving submit через <button form={formId} type=submit>).
  - Dirty-count считается из RHF formState.dirtyFields (top-level + data.*).
  - useEffect surfaces count к parent — drawer footer caption updates live.

- src/routes/dictionaries.$name.tsx
  - Modal create/edit -> RecordDrawer.
  - State: + recordDirtyCount (reset на close).
  - onDelete wires к existing close-confirm flow (kind: 'close-confirm').
  - onHistory wires к existing setHistoryKey RecordHistoryDrawer.

- src/i18n.ts
  - + form.delete, form.unsavedNFields_one/few/many/other (i18next plural rules).
  - + drawer.{collapse,expand,close,captionEdit,captionCreate}.

Phase B (TODO Stage 3.5b — separate MR):
- Section anchor chips (sticky strip)
- Wide-mode left ToC rail (180px)
- Sticky toolbar: search + 'только заполненные' toggle + counter
- Requires x-section schema metadata в backend properties (CEO open question #5 в handoff)

Tests: 8 files, 116 tests pass. TS strict clean.
2026-05-11 14:39:38 +03:00
Zimin A.N. b94912789f fix(fonts): semantic typography utilities per handoff (7 roles)
Заменяет blanket override из MR !45 на типизированную type scale per
design_handoff_ordinis_mdm/README.md "Scale" section.

Семь semantic @utility в styles.css:
  text-title-xl  22/600   — modal title, section header
  text-title-lg  17/600   — page title в editor
  text-title-md  15/600   — dictionary card title
  text-body      13/400   — workhorse: body, buttons, tabs, inputs
  text-cell      12.5/500 — table cell text
  text-mono      11/500   — Mono: IDs, dates, FK refs (font-mono baked in)
  text-cap       10.5/600 — Tektur UPPERCASE caption (font/uppercase baked in)

Audit phases:
  P1: добавил 7 утилит, font/uppercase/tracking baked где надо
  P2: 43 deterministic codemods (font-mono+text-2xs/[11px] → text-mono,
      [15/17/22]px+font-semibold → text-title-*, [12.5]px → text-cell,
      [10.5]px+uppercase+tracking → text-cap)
  P3: 64 text-sm → text-body (handoff workhorse), 84 text-2xs context-aware
      (TableCell → text-cell, bare → text-cell, плюс cleanup caps в backticks
      template literals который Phase 2 пропустил), 25 text-xs (6 → text-mono
      когда с font-mono, 19 → text-cell), 8 titles text-base/lg → text-title-*
  P4: убрал --text-2xs override (no users), оставил --text-sm: 13px scoped
      к @nstart/ui passthrough (см. comment в styles.css — убирается в Stage 3.9)

Stats:
  text-body: 69 | text-cell: 99 | text-mono: 50 | text-cap: 42
  text-title-xl: 5 | text-title-lg: 5 | text-title-md: 7
  text-sm/text-2xs/text-xs в src/: 0 (только в styles.css комментариях)

Поведение всех 277 typography usages теперь явно соответствует handoff —
каждое место осознанно выбрано под роль, не плажирующий override.
2026-05-11 14:31:35 +03:00
Zimin A.N. 71432e9ae7 fix(fonts): align all text sizes to handoff spec via Tailwind theme overrides
Глобальные правки через @theme в styles.css вместо 200+ точечных правок:

- text-sm: 14px -> 13px (handoff workhorse — body, buttons, tabs)
- text-2xs: undefined -> 11px (handoff IDs, mono meta — было невидимо)
- @utility text-cap: 10.5px Tektur uppercase 0.10em (handoff caps)

Codemod (30 sites): text-2xs + uppercase + tracking-[0.10em] -> text-cap.
Sidebar section labels consolidated с explicit text-[10.5px] на text-cap.

Покрывает 64 text-sm + 148 text-2xs автоматически. Table cells (12.5px)
и page/modal titles (17px/22px) остаются explicit text-[Npx] arbitrary
values по месту — нет slots в Tailwind scale.
2026-05-11 14:12:57 +03:00
Zimin A.N. 0ddba871f7 feat(admin-ui): editor 6-tab layout (Stage 3.4)
Dictionary editor route теперь имеет TabBar (handoff design Stage 3.4):
  Records | Relations | Fields | JSON | Events | History

URL-synced через ?tab= (default: records). Backward-compat: старый
?view=hub auto-remap'нется в ?tab=relations при mount'е.

Tab contents:
- records (default): существующий records table + filters + edit modal
  flow — без изменений, обёрнут в conditional render
- relations: DictionaryHubView (был раньше при view=hub)
- fields: schema properties summary — name/type/required/unique/i18n/FK/desc
- json: pretty-printed schemaJson read-only
- events: link к /audit с pre-filled dict filter
- history: placeholder (schema version timeline — backend endpoint pending)

EditorTabBar — inline component с handoff styling (border-b border-line +
active accent border + font-semibold), -mb-px overlap для clean look.

Hub view's neighbor cards теперь search={tab:'relations'} вместо
view='hub' (consistent с new param).

DictionaryDependentsPanel рендерится только в Records tab — auto Hub view
в Relations tab уже включает dependents через свой layout.

Tests: 116/116 PASS, build green, TS clean.
2026-05-11 13:54:26 +03:00
Александр Зимин 6db2a0c345 feat(admin-ui): catalog refresh + token migration (Stage 3.2) 2026-05-11 09:08:38 +00:00
Александр Зимин b7d2fbd7ce feat(admin-ui): codemod @nstart/ui → @/ui (Stage 2.2) 2026-05-10 22:17:06 +00:00
Александр Зимин c57003cd73 fix(admin-ui): guest nav filter + suppress drafts 401 2026-05-10 21:19:07 +00:00
Александр Зимин 94da10f487 fix(admin-ui): TimeTravelPicker → visual SVG timeline 2026-05-10 19:50:17 +00:00
Александр Зимин 69f1a05a1f fix(admin-ui): v1.1.1 patch — version routing + guest mode + timeline UX 2026-05-10 19:34:47 +00:00
Александр Зимин 48dd8a487a fix(admin-ui): backlog sweep — 7 review items 2026-05-10 19:21:33 +00:00
Александр Зимин 0918820661 fix(admin-ui): Checkbox indeterminate stale state 2026-05-10 18:53:07 +00:00
Александр Зимин 1df232679d feat(admin-ui): Hub view (B) per design handoff — dict relations 3-col layout 2026-05-10 18:41:37 +00:00
Александр Зимин f5fb2b70a6 fix(admin-ui): dict detail toolbar — wrap buttons вместо ломки текста 2026-05-10 17:30:38 +00:00
Александр Зимин fc2fb35717 fix(admin-ui): top-5 critical review bugs (auth + AJV + TZ + idempotency + storage) 2026-05-10 16:22:35 +00:00
Zimin A.N. b2e7fd84c9 feat(approval): Approval Workflow v2 Phase 4 monitoring + pending-review badges
Approval Workflow v2 — Phase 4: monitoring + UX visibility.

Backend metrics (Micrometer → Prometheus):
- Gauge nsi_draft_pending_total — global queue depth (polled на scrape).
- Counter nsi_draft_submit_total{dictionary,operation,outcome=ok|already_pending|error}
  — submit attempts с разбивкой по outcome. Phase 3 lessons: error outcome
  выявит schema bugs которые ранее маскировались.
- Counter nsi_draft_decision_total{dictionary,operation,outcome=approved|rejected|withdrawn}
  — counts decisions, разбит по типу.
- Timer nsi_draft_review_duration_seconds — time-to-decide histogram (от
  submit до approve/reject/withdraw). publishPercentileHistogram() — для
  Prometheus histogram_quantile p95/p99.

DraftService:
- Optional<MeterRegistry> — null-safe в test ctor'ах.
- @PostConstruct registerGauges() — Gauge.builder polled от
  RecordDraftRepository.countPending().
- recordDecision() helper в approve/reject/withdraw — записывает Timer +
  Counter одной точкой.
- 4-arg ctor compat для Phase 1 unit tests.

UI (admin):
- useDictPendingDrafts — per-dict pending list, refetch 30s, gated на
  detailQuery.data.approvalRequired (no-op для не-approval dicts).
- "На review" badge в businessKey cell records table — моментальная
  видимость что есть pending draft. Open /reviews для act'а.
- i18n RU/EN: dict.pendingReview.label + tooltip.

Domain:
- RecordDraftRepository.countPending() — для Gauge polled count.

Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
2026-05-08 15:11:25 +03:00
Zimin A.N. 08405fecf7 feat(admin-ui): time-travel UI — view dictionary as-of any past date
CEO plan v1 stretch — закрывает gap "Time-travel UI (slider)".

Backend уже поддерживает ?at=<ISO> в read-api endpoints (DictionaryReadController):
findActiveAt(dict, key, at) возвращает active version на этот момент.
Был только UI gap.

UI:
- New toolbar button "Просмотр на дату" с ClockCounterClockwiseIcon.
  aria-pressed когда timeTravelAt активен.
- Datetime picker раскрывается inline (не модал — quick interactions).
  Native <input type=datetime-local>, конвертируется ↔ ISO.
- Active state ambient banner (orbit/8 background) если ?at= задан + picker
  закрыт — показывает "Просмотр на: {date}" + "Сейчас" клир link.
- "Закрыть" кнопка для picker'а; "Сейчас" сбрасывает at.

URL searchParams:
- DictSearch type расширен `at?: string` (ISO datetime).
- validateSearch проверяет `Date.parse(at)` — invalid timestamps drop'ятся.
- Share-friendly: "ordinis.k8s.265.../dictionaries/spacecraft?at=2025-12-31T00:00:00Z"
  работает per F5 + ссылка коллеге.

Records query:
- RecordsFilter type +at?: string.
- recordsQuery передаёт ?at=… backend'у в дополнение к as_scope/bbox/polygon.
- queryKey включает at — switching между моментами trigger'ит refetch.

i18n RU/EN: timeTravel.{button,label,viewing,clear,close}.

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backend смену не требует — DictionaryReadController.list() уже принимал
@RequestParam(value = "at") OffsetDateTime at и роут'ил в RecordReadService.
2026-05-08 12:39:53 +03:00
Zimin A.N. 3eeaba058f feat(cascade): Phase 3 — CascadeCloseService + 409 routing + UI confirm dialog
dict-relationships-v2 epic, Phase 3. Cascade engine + UX guards.

Backend:
- New CascadeCloseService:
  * evaluatePlan(target, key, at) — read-only, splits dependents по
    onClose mode (BLOCK / WARN / CASCADE).
  * executeCascade(target, key, when, reason, confirmed) —
    @Transactional(timeout=30, isolation=SERIALIZABLE) atomic close
    target + all CASCADE deps в one hop (design F.OUT). 503
    x_references_cascade_timeout если tx exceeds 30s, 400
    x_references_cascade_too_large если N > 500.
  * RecordCloseSink interface — test seam над repo+audit+outbox
    mechanics (concrete classes на JDK 25 + Mockito incompat).
- Wired DictionaryRecordService.close():
  * Optional<CascadeCloseService> inject через ctor (back-compat ctor
    для тестов с null deps).
  * Pre-close evaluatePlan: blockers → 409 x_references_blocked_by_dependents,
    cascade non-empty → 409 x_references_cascade_required (просит cascade-close
    endpoint). WARN-only — close проходит.
- Endpoints в DictionaryRecordController:
  * GET /dictionaries/{dict}/records/{key}/cascade-preview — read-only план.
  * POST /dictionaries/{dict}/records/{key}/cascade-close?confirmed=true —
    atomic execute. Existing DELETE остался (теперь учитывает план).

Tests (8 unit tests):
- evaluatePlan empty / split-by-mode (BLOCK/WARN/CASCADE)
- 409 blockers exist (not closed)
- 409 cascade-required без confirmed
- WARN-only — closes target only, warnings returned
- CASCADE — atomic close target + 2 cascaded (verifies order + reasons)
- BLOCK overrides CASCADE — 409 даже при confirmed=true
- Empty plan — closes only target

Admin UI:
- New CascadeConfirmDialog component:
  * Trigger: 409 на existing close → opens dialog с GET cascade-preview.
  * BLOCKERS section — Alert error, list первых 5, "resolve first" hint.
  * CASCADE section — per-source grouping, sample 3 + overflow,
    badges per (count, onClose mode).
  * WARN section — yellow Alert "будут orphan".
  * Reason input + "Type CONFIRM" gate если N > 50 (UX guard).
  * Cancel — close без changes.
- Wired в dictionaries.$name.tsx: existing close-confirm flow на 409
  cascade error pivots в new dialog (same UX path, transparent для user).
- Types в client.ts: CascadeEntry, CascadePlan, CascadeCloseResult.
- Mutation useCascadeCloseRecord, query useCascadePreview.
- i18n RU/EN: cascade.* (35 keys, plurals для счётчиков).

Verify:
- mvn -pl ordinis-rest-api -am test: 106/106 PASS (8 new cascade tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (5.1s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
default → existing dependents теперь блокируют close (правильное поведение
для FK semantics). Если был race condition close-with-deps в v1, он
exposed теперь как 409. CRITICAL: REGRESSION test покрывает.
2026-05-08 11:23:46 +03:00
Zimin A.N. 6a365fcef7 feat(admin-ui): Phase 1 lineage UI — "Used by" + record refs cards
dict-relationships-v2 epic, Phase 1 admin UI. Backend endpoints поднимаются
коммитом 46a0a9c, тут — UI surface'ит их.

Что добавлено:
- `DictionaryDependentsPanel` — schema-level "На этот словарь ссылаются:"
  panel на странице справочника. Chip per (sourceDict.field → targetField,
  active records count, onClose mode badge: block/warn/cascade).
  Hide-on-empty: большинство справочников без incoming refs panel не видят.
- `RecordDependentsPanel` — record-level "На эту запись ссылаются:" section
  внутри `RecordHistoryDrawer`. Per-source summary chips + paginated items
  list (size=20, prev/next). Hide-on-empty.

Types в `client.ts`: `OnCloseAction`, `SchemaDependent`, `RecordDependent`,
`PerSourceSummary`, `RecordDependentsPage`.

Queries в `queries.ts`:
- `useDictionaryDependents(name)` — staleTime 5min (schemas slow-moving),
  404/403 → empty.
- `useRecordDependents(dict, key, page, size)` — disabled пока key undefined.

Mount points:
- `routes/dictionaries.$name.tsx`: `<DictionaryDependentsPanel>` после
  PageHeader, перед AOI banner.
- `components/record/RecordHistoryDrawer.tsx`: `<RecordDependentsPanel>`
  выше existing version timeline.

i18n RU/EN:
- `lineage.usedBy.{title,count,activeCount,openSourceDict}`
- `lineage.refs.{title,total,field,created,openSourceRecord}`
- `lineage.onClose.{BLOCK,WARN,CASCADE}` (показ как лейблы)
- `pagination.{prev,next,pageOf}` — общие keys.

Plurals: `_one/_few/_many/_other` для RU (правильная грамматика count'ов).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS (4.04s).
- pnpm build: clean (warnings о chunk size — pre-existing для @nstart/ui).

Бекенд endpoints живут на:
- GET /api/v1/dictionaries/{name}/dependents
- GET /api/v1/records/{dict}/{key}/dependents?page&size
2026-05-08 10:52:46 +03:00
Zimin A.N. 4252ad93a8 feat(records): bulk export CSV выбранных + pagination
Backend POST /api/v1/dictionaries/{name}/records/export-selected.csv:
принимает businessKeys[] (1..500), возвращает CSV с колонками
business_key, valid_from, valid_to, scope, data (JSONB inline).
Не-найденные ключи отдаются строкой с пустыми ячейками — admin видит
что ключ обработан, но данных нет. RFC 4180 экранирование.

requireReadAccess: для экспорта достаточно прав чтения (не write).

POST а не GET потому что >100 ключей не помещается в URL params
(Server: Request URI Too Long).

Frontend useBulkExportRecords: axios POST с responseType: blob →
temporary <a download> link → клик → revokeObjectURL через rAF.
Filename берётся из Content-Disposition header.

Toolbar: новая кнопка "Экспорт CSV" (variant=secondary) рядом с
"Закрыть выбранные" (variant=danger). Loading state через isPending.

Pagination для records list (PAGE_SIZE=50, client-side):
- visibleRecords = filteredRecords.slice(...)
- Footer "Записи 1–50 из 100, Стр. 1 из 2" с Prev/Next кнопками
- Auto-reset на page=0 при изменении filter/scope/AOI
- selectAll/visibleKeys теперь = только current page (WYSIWYG)
- При >50 записей видно навигацию, при <=50 footer скрыт

Прогон: 20/20 e2e + 60/60 rest-api + 89/89 admin-ui.
2026-05-07 12:12:31 +03:00
Zimin A.N. 5941207955 feat(records): bulk close — multi-select toolbar + per-record транзакция
Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].

Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).

Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.

Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
2026-05-07 11:19:57 +03:00
Zimin A.N. 5bcc9586c7 feat(dict): records list — schema-driven extra columns (FK + enum)
Раньше records list показывал только businessKey / name / scope /
locale / validFrom. На spacecraft с 14 полями admin не видел статуса
или типа КА — должен был открыть edit modal каждой записи чтобы
понять что есть OPERATIONAL и что есть LOST.

Drop unhelpful 'locale' колонку (для не-localized словарей всегда '—').
Replace на до 2 schema-driven 'значимых' колонок:
- FK поля (x-references) — синий fill, маркируют ссылку на dict
- Enum поля — серый fill, fixed allow-list

Spacecraft теперь показывает в таблице: businessKey, name,
satellite_type_code (FK), status (FK), scope, validFrom — всю
полезную информацию за один взгляд.

Schema interpretation:
- detailQuery.data.schemaJson.properties → filter scalar non-localized
- Skip name/code/businessKey (уже показаны)
- Take первые 2 enum/reference (column count fits 1280px viewport)
- humanize key для header label (snake_case → Snake Case)

Tests: 89 passed (без изменений — render-логика, integration test
требует QueryClient + schema fixture, ROI низкий).
2026-05-06 21:10:19 +03:00
Zimin A.N. 5e1b39db03 fix(dict): name column в records list — pick localized value, не [object Object]
Раньше для записей с x-localized name (КА, антенна, ground_station,
operator) колонка 'name / code' показывала literal '[object Object]'
потому что String({'ru-RU': 'Канопус', 'en-US': 'Kanopus'}) =
toString() стандартный → '[object Object]'.

Решение:
- Новый shared util lib/locales.ts:
  - pickLocalized(value, defaultLocale) — извлекает строку из i18n
    объекта или plain string. Fallback на first non-empty key
    если defaultLocale не найден.
  - recordDisplayName(data, defaultLocale) — name → code → '—' chain.
- Records list использует recordDisplayName с dict.defaultLocale
  (берётся из DictionaryDetail).
- SchemaDrivenForm дедуплицирован — был свой локальный pickLocalized,
  теперь импортирует из util'ы.

Tests: +13 (76 → 89), unit tests на pickLocalized + recordDisplayName
покрывают все edge cases (plain string, localized object, fallback
chain, null/undefined input).
2026-05-06 21:05:50 +03:00
Zimin A.N. d5a28d251d feat(dict): URL search params для filter state — share-friendly persistence
Раньше search/scope/AOI filters жили только в локальном useState —
F5 сбрасывал, ссылку с применёнными фильтрами не расшарить.

Теперь:
- ?q=text — search query
- ?scopes=PUBLIC,INTERNAL — comma-separated subset (empty = все)
- ?bbox=west,south,east,north — bbox AOI (server-side spatial filter)
  Polygon AOI остаётся в local state — слишком длинно (50+ точек) для
  URL, рендерили бы /dictionaries/foo?polygon={...} с сотнями символов.

validateSearch на route + Route.useSearch() читает; navigate replace:true
обновляет URL без новой history entry на каждый keystroke search'а.

Bootstrap: при mount если ?bbox= — рендерим bbox-AOI initial state;
картинку с прямоугольником сразу видно в picker'е если открыть.

Use cases:
- Bookmark filtered view словаря
- Поделиться ссылкой '/dictionaries/spacecraft?q=Канопус&scopes=PUBLIC'
- F5 не теряет filters
2026-05-06 20:30:40 +03:00
Zimin A.N. b4281de41f feat(dict): records filter — search + scope chips client-side
На странице словаря над таблицей появилась filter bar:
- SearchInput (поиск по businessKey + JSON.stringify(data) — покрывает
  локализованные name, code, описания)
- 3 scope chips PUBLIC/INTERNAL/RESTRICTED с цветными dot'ами,
  toggle включает/исключает scope. Empty Set = все scope видны
  (default state).
- Когда фильтры активны — показываем 'Найдено X из Y' + кнопка
  Сбросить.
- Если фильтр выдал 0 результатов — EmptyState с пояснением
  что под фильтр ничего не подошло.

Фильтрация client-side через useMemo. Записей в одном словаре
обычно ≤100, JSON.stringify + includes() мгновенно. AOI остаётся
server-side (геометрия требует PostGIS) — фильтры комбинируются.

i18n: RU + EN ключи.
2026-05-06 20:21:05 +03:00
Zimin A.N. 05af8e1520 feat(geo): AOI picker — Leaflet draw → bbox/polygon фильтр записей
Admin кликает 'AOI фильтр' на странице словаря → открывается
dialog с картой OSM. Рисует rectangle (→ bbox CSV) или
polygon → onApply применяется к recordsQuery как ?bbox= или
?polygon=GeoJSON.

Server-side parsing уже есть (BoundingBox.parse, GeoJsonPolygon.parse
в ordinis-rest-api) — подключили UI поверх.

Активный AOI отображается inline над таблицей с кнопкой
'Сбросить'. Edit shape возможен через leaflet-draw edit/remove
controls.

Зависимости: leaflet 1.9.4 + react-leaflet 5 + leaflet-draw 1.0.4
+ types. Bundle +231KB gzip 62KB — приемлемо для AOI-only feature
(map работает без MapLibre WASM, OSM tiles — публичные).

Picker self-contained: монтируется/уничтожается с диалогом,
invalidateSize 100ms после open чтобы Leaflet корректно посчитал
container size после modal animation.
2026-05-06 20:01:14 +03:00
Zimin A.N. 9b20cbf9b9 chore(admin-ui): fix pre-existing TypeScript errors
5 ошибок TS, накопленных за прошлые итерации — теперь pnpm tsc зелёный:

- SchemaDrivenForm: убран unused import formatIsoDateTime + unused
  prop `path` (передавался в FieldRenderer, но FieldRendererProps его
  не объявлял и FieldBody не использовал)
- dictionaries.$name: удалена неиспользуемая функция toIsoDate
- api/client.ts: добавлен `$schema?: string` в JsonSchema type
  (используется в buildSchemaJson и тестируется в types.test.ts)
2026-05-06 13:22:17 +03:00
Zimin A.N. 3f2a6b5d53 feat(admin-ui): scope accent on dictionary detail page
Перенесли scope-визуальные константы (SCOPE_ORDER, SCOPE_DOT,
SCOPE_ACCENT) в `lib/scope-style.ts` для переиспользования. На detail
странице (`dictionaries/$name`) добавили top accent strip 4px цветом
scope + цветной чип в breadcrumb рядом с back-link. Теперь при переходе
из списка в карточку scope сразу виден без чтения badge в таблице.
2026-05-06 12:50:44 +03:00
Zimin A.N. f7efb23e09 test+ci: 51 frontend unit-тестов (Vitest) + CI rollback на single resolve-tag
Tests (Vitest):
- src/lib/dates.ts (вынесено из SchemaDrivenForm + dictionaries.$name): parseFormDate,
  formatIsoDate, formatIsoDateTime, extractTime, combineDateTime, localTzOffset, nowIsoLocal
- src/lib/dates.test.ts (20 tests): TZ-safety, pure-date local midnight, far-future filter,
  round-trip parse/format, time extraction в local TZ, clear-button (null Date → '')
- src/components/schema/types.test.ts (31 tests): buildSchemaJson всех 9 kinds + x-unique +
  x-id-source, parseSchemaJson roundtrip, diffSchemas (8 breaking detection cases),
  suggestVersionBump (major/minor/patch + breaking-wins-mix)

CI:
- Откатил dual resolve-tag (BACKEND/FRONTEND) на single resolve-tag.
  GitLab variable interpolation в trigger jobs не подхватывает dotenv от needs-job
  → admin-ui-only push не доезжал до infra. Now backwards compat path работает
  как раньше с UPSTREAM_IMAGE_TAG.
- Backend docker jobs (maven-package + 4 docker-*) теперь без rules:changes —
  catch ImagePullBackOff если frontend push шёл без backend rebuild. BuildKit cache
  делает повторный билд идентичных layers ~30 sec.
- Path-filter оставлен на maven-test (тесты не запускаются на frontend-only PR
  — они бэкендовые) и на docker-admin-ui (frontend-only).

Run: pnpm test → 51 passed.
2026-05-04 04:37:24 +03:00
Zimin A.N. 303a5f0402 feat(ci+ux): path-filtered CI build + Edit semantics в bitemporal model
CI: path-filtering (наконец-то)
- Backend jobs (maven-test, maven-package, docker-app/read-api/projection-writer/migrations,
  resolve-backend-tag) запускаются только при backend-changes (Java модули, pom.xml, CI yml).
- Frontend (docker-admin-ui, resolve-frontend-tag) — только при ordinis-admin-ui/**.
- Trigger downstream передаёт BACKEND_IMAGE_TAG и/или FRONTEND_IMAGE_TAG. Backwards
  compat для старого UPSTREAM_IMAGE_TAG сохранён в infra pipeline.
- Frontend-only PR теперь не катит backend rolling update (raньше каждый push
  тегал ВСЕ сервисы новым sha и роллил).

Edit dialog UX (bitemporal):
- При Edit validFrom default = now() (а не historical validFrom существующей записи).
  Раньше юзер сдвигал validFrom назад → backend не находил активной версии в этот
  момент → 'record_not_active'. Теперь по умолчанию 'новая версия с этого момента'.
- nowIsoLocal() в timezone браузера (с offset).
- validTo default пустой = бессрочно.

Backend: понятное сообщение для record_not_active с инструкцией.
2026-05-04 04:24:17 +03:00
Zimin A.N. baf76ac6e0 feat(admin-ui): D-pack — history, breaking-change detection, x-unique, x-id-source, datetime
History:
- Drawer на детальной странице записи (timeline всех версий через GET /records/{key}/history)
- Иконка ClockCounterClockwise в actions колонке таблицы записей
- Каждая версия показывает v(version), validFrom/To, updatedBy + expand JSON

Schema diff (breaking detection):
- Util diffSchemas() сравнивает старую/новую схему и классифицирует изменения:
  breaking (removed/typeChanged/becameRequired/enumRemoved/maxLengthTightened/
  patternChanged/newRequiredField), compatible (added optional/enumAdded/
  becameOptional), metadata (description)
- В DictionaryEditorDialog при breaking → Alert с детальным списком + Save заблокирован
- При compatible → Alert info со списком
- suggestVersionBump: major для breaking, minor для compatible field changes,
  patch для metadata-only

x-unique:
- Чекбокс «Уникальное» на PropertyEditor → emit x-unique: true в schema
- Backend enforcement позже — отдельный PR с partial unique index

x-id-source:
- SingleSelect в Метаданных «Использовать как ID» → emit x-id-source на schema root
- В SchemaDrivenForm если установлен — businessKey input скрывается, на submit
  derived из data[idSource]
- Подсказка с именем поля

DateTime для validFrom/validTo:
- Заменил DatePicker (date-only) на DateTimeField компонент: DatePicker + TextInput
  type=time рядом
- Default time: validFrom=00:00, validTo=23:59 (запись действует до конца дня
  иначе зануляется в полночь)
- Поддерживает 2-часовые интервалы (запуск КА в 14:30 → сход в 02:15 etc.)

Bug fix:
- parseSchemaJson изменил сигнатуру с PropertyDef[] на {properties, idSource},
  старый код в DictionaryEditorDialog падал "{} is not iterable". Поправлено
  через destructuring const parsed = parseSchemaJson(...).

i18n: 25+ новых ключей (RU/EN) — schema.unique, schema.idSource, schema.diff.*,
history.*, form.idSourceNote.
2026-05-04 03:38:21 +03:00
Zimin A.N. 987559a6ab feat(admin-ui): visual schema builder для создания/редактирования справочников
Новый функционал — заводить свои справочники через UI без code deploy:

API:
- CreateDictionaryRequest type в client.ts
- useCreateDictionary / useUpdateDictionary mutations с invalidate
  ['dictionaries'] и ['dictionary', name]

Компоненты (src/components/schema/):
- types.ts: PropertyDef + buildSchemaJson() (Draft-07) + parseSchemaJson()
  для двусторонней конвертации между UI state и schema_json
- PropertyEditor: name + kind select + required + description + type-specific
  опции (minLength/maxLength/pattern для string; minimum/maximum для number;
  enum values; uniqueItems для array). Up/Down/Trash icon buttons
- SchemaBuilder: список property cards + кнопка «добавить» + EmptyState
- DictionaryEditorDialog: модалка с 3 табами:
  * Метаданные (name/displayName/description/scope/bundle/version/locales)
  * Поля (SchemaBuilder)
  * JSON (readonly preview сгенерированной schema_json)

Поддерживаемые типы полей: string, integer, number, boolean, enum,
localized (i18n object с patternProperties), date (format=date),
datetime (format=date-time), array_string (с опц. enum + uniqueItems).

Wiring:
- /dictionaries: PageHeader actions = «+ Создать справочник» → Dialog в режиме create
  → onSuccess navigate на /dictionaries/{name}
- /dictionaries/$name: PageHeader actions расширен «Схема» (secondary) +
  «Создать запись» (primary). Dialog в edit-режиме prefilled из
  detailQuery.data + parseSchemaJson()

i18n: 30+ новых ключей в schema.* / scope.* (RU/EN)
2026-05-04 03:12:56 +03:00
Zimin A.N. ef31499bd9 fix(admin-ui): DatePicker + nested object + raw record fetch для Edit
Несколько связанных багов в форме записей:

DatePicker:
- launch_date / decay_date (format=date) и validFrom/validTo рендерились как
  raw text input → теперь @nstart/ui DatePicker с RU локалью.
- DatePickerProvider добавлен в main.tsx с DATE_PICKER_LOCALE_RU.
- Far-future infinity dates (year>=9999) скрываются (пустое поле).
- ISO date format на submit: yyyy-MM-dd.

Nested object → JsonField fallback:
- orbit (type=object с raan_deg/eccentricity/...) и antennas (array of object)
  показывали [object Object] в text input. Добавлен JsonField — TextArea
  с JSON.stringify/parse, локальный state для невалидного промежуточного
  ввода + inline parse error.

Edit raw record fetch:
- Read-api flatten'ит i18n в строку выбранной локали → form для edit получал
  data.name = 'Terra' вместо {ru-RU, en-US}. На Edit теперь fetch raw record
  через writer GET /api/v1/dictionaries/{name}/records/{businessKey}.
- nginx regex location ~ ^/api/v1/dictionaries/ → writer (вместо узкого regex
  только для /dictionaries/{name}). GET schema, GET raw record, mutations —
  всё на writer. Read-api держит /api/v1/{name}/records/* (другой URL pattern).
- LoadingBlock пока тащим raw запись + Alert на ошибке.
2026-05-04 03:01:08 +03:00
Zimin A.N. 43a06d1a7b fix(admin-ui): translate field labels + responsive header + modal max-h
Issues:
- Field labels (ORBIT/STATUS/COUNTRY/MASS_KG/...) показывались raw'ом потому
  что backend schema не присылает title. Добавил humanize() (snake_case → 'Snake Case')
  + i18n fallback t('field.$key') для известных полей spacecraft/satellite_type/
  ground_station: name, designator, norad_id, status, country, operator, mass_kg,
  mission, orbit, launch_date, decay_date, spectrum_bands, satellite_type_code, etc.
- Modal panel рос в высоту бесконечно (nstart-ui Modal не клампит max-h),
  на iPad портрете форма уходила за viewport. panelClassName=max-h-[calc(100vh-2rem)]
  flex flex-col + bodyClassName=overflow-y-auto flex-1.
- Header в __root: padding/gap responsive (px-4 sm:px-6, py-3 sm:py-4, gap-3 sm:gap-6),
  flex-wrap чтобы LanguageSwitch на узких экранах переносился под навигацию.
- Main container: px-4 sm:px-6 + py-4 sm:py-8.
2026-05-04 02:47:06 +03:00
Zimin A.N. 7aed67fce5 fix(admin-ui): IconButton icon prop + compact 2-col form layout
Bugs:
- IconButton @nstart/ui принимает icon={} prop, не children — иконки рендерились
  пустыми квадратами (видно «Изменить» tooltip без значка карандашика).
  Перевёл оба IconButton на icon={<PencilSimpleIcon/>} / icon={<XCircleIcon/>}.

UX:
- Modal max-w-2xl → max-w-4xl (форма дышит, ru-RU/en-US labels не оборачиваются).
- SchemaDrivenForm: scalar поля теперь в 2-колоночной grid, full-width
  (i18n / array / textarea / nested object) занимают sm:col-span-2.
- Локальные ярлыки i18n: tracking-normal + whitespace-nowrap, w-14 — больше не
  ломаются «ru-» «RU» из-за inherited tracking-label.
2026-05-04 02:37:13 +03:00