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.
This commit is contained in:
Zimin A.N.
2026-05-11 16:33:40 +03:00
parent f43ce5563a
commit a57cf520a4
2 changed files with 184 additions and 8 deletions
@@ -0,0 +1,164 @@
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/ui'
import { useDictionaryDependents } from '@/api/queries'
import type { DictionaryDetail } from '@/api/client'
/**
* EditorInfoPanel — left rail с dict metadata per handoff prototype Screen 2.
*
* <p>Содержимое (top → bottom):
* <ul>
* <li>Scope badge (PUBLIC/INTERNAL/RESTRICTED)</li>
* <li>ОПИСАНИЕ caption + description text</li>
* <li>BUNDLE / ВЕРСИЯ / ЗАПИСЕЙ / ЛОКАЛИ metadata grid</li>
* <li>СВЯЗИ — → ссылается (outgoing FK names) + ← используют (incoming dict names)</li>
* </ul>
*
* <p>Action buttons (Экспорт / Time-travel) идут в PageHeader actions slot,
* не в info panel — handoff prototype показывает им в info panel но семантика
* редизайна: actions около title для discoverability.
*/
export type EditorInfoPanelProps = {
detail: DictionaryDetail
recordCount: number
}
export function EditorInfoPanel({ detail, recordCount }: EditorInfoPanelProps) {
const { t } = useTranslation()
const { data: refByRaw } = useDictionaryDependents(detail.name)
const outgoingNames = useMemo(() => extractOutgoingFk(detail.schemaJson), [detail.schemaJson])
const incomingNames = useMemo(() => {
const seen = new Set<string>()
for (const dep of refByRaw ?? []) seen.add(dep.sourceDict)
return Array.from(seen).sort()
}, [refByRaw])
return (
<aside className="lg:sticky lg:top-2 self-start space-y-4 lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto pr-2">
{/* Scope badge — top of panel */}
<Badge variant="info">{detail.scope}</Badge>
{/* Description */}
{detail.description && (
<Section label={t('editor.info.description', { defaultValue: 'Описание' })}>
<p className="text-body text-ink-2 leading-relaxed">{detail.description}</p>
</Section>
)}
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<MetaCell label={t('editor.info.bundle', { defaultValue: 'Bundle' })}>
<span className="text-mono text-ink-2">{detail.bundle}</span>
</MetaCell>
<MetaCell label={t('editor.info.version', { defaultValue: 'Версия' })}>
<span className="text-mono text-ink-2">v{detail.schemaVersion}</span>
</MetaCell>
<MetaCell label={t('editor.info.records', { defaultValue: 'Записей' })}>
<span className="text-mono text-ink-2 tabular-nums">{recordCount}</span>
</MetaCell>
<MetaCell label={t('editor.info.locales', { defaultValue: 'Локали' })}>
<span className="text-mono text-ink-2">{detail.supportedLocales.join(', ')}</span>
</MetaCell>
</div>
{/* Relations */}
{(outgoingNames.length > 0 || incomingNames.length > 0) && (
<Section
label={t('editor.info.relations', {
defaultValue: 'Связи',
count: outgoingNames.length + incomingNames.length,
})}
>
{outgoingNames.length > 0 && (
<div className="space-y-1.5 mb-3">
<div className="text-cap text-mute">
{t('editor.info.outgoing', { defaultValue: 'ссылается' })}
</div>
<ul className="space-y-0.5">
{outgoingNames.map((name) => (
<li key={name}>
<Link
to="/dictionaries/$name"
params={{ name }}
className="text-mono text-accent hover:underline"
>
{name}
</Link>
</li>
))}
</ul>
</div>
)}
{incomingNames.length > 0 && (
<div className="space-y-1.5">
<div className="text-cap text-mute">
{t('editor.info.incoming', { defaultValue: 'используют' })}
</div>
<ul className="space-y-0.5">
{incomingNames.map((name) => (
<li key={name}>
<Link
to="/dictionaries/$name"
params={{ name }}
className="text-mono text-accent hover:underline"
>
{name}
</Link>
</li>
))}
</ul>
</div>
)}
</Section>
)}
</aside>
)
}
function Section({
label,
children,
}: {
label: React.ReactNode
children: React.ReactNode
}) {
return (
<div className="space-y-1.5">
<div className="text-cap text-mute">{label}</div>
{children}
</div>
)
}
function MetaCell({
label,
children,
}: {
label: React.ReactNode
children: React.ReactNode
}) {
return (
<div className="space-y-1">
<div className="text-cap text-mute">{label}</div>
<div>{children}</div>
</div>
)
}
const extractOutgoingFk = (
schema: import('@/api/client').JsonSchema | undefined,
): string[] => {
if (!schema?.properties) return []
const targets = new Set<string>()
for (const prop of Object.values(schema.properties)) {
const ref = prop['x-references']
if (typeof ref === 'string' && ref.length > 0) {
const [target] = ref.split('.')
if (target) targets.add(target)
}
}
return Array.from(targets).sort()
}
@@ -42,6 +42,7 @@ import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { RecordDrawer } from '@/components/record/RecordDrawer' import { RecordDrawer } from '@/components/record/RecordDrawer'
import { WorkflowBanner } from '@/components/editor/WorkflowBanner' import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker' import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
import { nowIsoLocal } from '@/lib/dates' import { nowIsoLocal } from '@/lib/dates'
@@ -621,14 +622,22 @@ function DictionaryDetail() {
} }
/> />
{/* 3-col layout per handoff prototype design/compact.html:
[Sidebar (220px global)] [InfoPanel 220px] [main content]
Info panel — sticky слева с dict metadata + relations.
На <lg viewport schedule переключается в stacked (info above content). */}
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6 mt-2">
{detailQuery.data && (
<EditorInfoPanel detail={detailQuery.data} recordCount={totalRecords} />
)}
<div className="min-w-0 space-y-4">
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */} {/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
{detailQuery.data && ( {detailQuery.data && (
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} /> <WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
)} )}
{/* Editor tabs per handoff Stage 3.4: {/* Editor tabs per handoff Stage 3.4 */}
Records | Relations | Fields | JSON | Events | History.
URL-synced через ?tab=. Default — records (records table + filters). */}
{detailQuery.data && ( {detailQuery.data && (
<EditorTabBar <EditorTabBar
active={urlSearch.tab ?? 'records'} active={urlSearch.tab ?? 'records'}
@@ -945,6 +954,9 @@ function DictionaryDetail() {
</> </>
)} )}
{/* === end records tab content === */} {/* === end records tab content === */}
</div>
</div>
{/* === end 3-col grid === */}
<RecordDrawer <RecordDrawer
open={edit.kind === 'create' || edit.kind === 'edit'} open={edit.kind === 'create' || edit.kind === 'edit'}