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'".
This commit is contained in:
Zimin A.N.
2026-05-11 19:01:57 +03:00
parent e15a3916bf
commit 5a2220d779
10 changed files with 1109 additions and 83 deletions
@@ -35,15 +35,15 @@ import { useCanMutate } from '@/auth/useCanMutate'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { RecordDrawer } from '@/components/record/RecordDrawer'
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
import { ExportModal } from '@/components/editor/ExportModal'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
@@ -188,6 +188,7 @@ function DictionaryDetail() {
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
* на close, или явным action из record menu. */
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
@@ -570,16 +571,9 @@ function DictionaryDetail() {
timeTravelActive: Boolean(timeTravelAt),
onAoi: () => setAoiOpen(true),
aoiActive: Boolean(aoi),
onExport: () => {
// Export all visible records (filtered + paginated set OR full
// dictionary). Если selection непустой — exports selection,
// иначе — все filtered records.
const keys =
selection.size > 0
? Array.from(selection)
: filteredRecords.map((r) => r.businessKey)
if (keys.length > 0) bulkExportMut.mutate(keys)
},
// Открываем ExportModal вместо immediate triggera — пользователь
// выбирает формат/scope/columns/encoding/delimiter.
onExport: () => setExportOpen(true),
exportPending: bulkExportMut.isPending,
}}
/>
@@ -664,11 +658,9 @@ function DictionaryDetail() {
<HistoryTabContent />
)}
{/* DictionaryDependentsPanel — отображается ТОЛЬКО на Records tab.
Phase 1 dict-relationships-v2 reverse FK card. Hide-on-empty. */}
{(!urlSearch.tab || urlSearch.tab === 'records') && (
<DictionaryDependentsPanel dictionaryName={name} />
)}
{/* DictionaryDependentsPanel inline-card удалён — reverse FK rows
* теперь живут в EditorInfoPanel ← используют (rich rows с field path
* + active count + onClose policy chip). Один source of truth. */}
{aoi && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-accent/30 bg-accent/4 text-body">
@@ -693,16 +685,22 @@ function DictionaryDetail() {
{(!urlSearch.tab || urlSearch.tab === 'records') && (
<>
{/* Time-travel picker — раскрывается по клику на toolbar button.
ISO datetime в URL ?at=… — share-friendly. Active state показывает
ambient banner справа от input'а. Phase v1 stretch. */}
{timeTravelOpen && (
<TimeTravelPicker
value={timeTravelAt}
onChange={(iso) => setTimeTravelAt(iso)}
onClear={() => setTimeTravelAt(undefined)}
{/* Time-travel modal — only mount когда open=true. Modal внутри
использует Radix Dialog который portals content при open. Но props
like `marks={recordTimestamps}` пересоздаются на каждый render
родителя (new array reference) — это нормально для уже mounted
компонента, но при always-mounted причиняет нестабильность store.
Pattern: lazy mount только если open. */}
{timeTravelOpen && detailQuery.data && (
<TimeTravelModal
open
onClose={() => setTimeTravelOpen(false)}
detail={detailQuery.data}
value={timeTravelAt}
onApply={(iso) => setTimeTravelAt(iso)}
onClear={() => setTimeTravelAt(undefined)}
marks={recordTimestamps}
totalRecords={totalRecords}
/>
)}
@@ -1125,6 +1123,20 @@ function DictionaryDetail() {
initial={aoi?.geojson ?? null}
/>
{/* Export modal — only mount when open. Same lazy pattern as Time-travel. */}
{exportOpen && detailQuery.data && (
<ExportModal
open
onClose={() => setExportOpen(false)}
detail={detailQuery.data}
totalCount={totalRecords}
filteredRecords={filteredRecords}
selection={selection}
onExportCsv={(keys) => bulkExportMut.mutate(keys)}
exportPending={bulkExportMut.isPending}
/>
)}
<Modal
isOpen={bulkOpen}
onClose={() => {