diff --git a/ordinis-admin-ui/src/components/editor/EditorInfoPanel.tsx b/ordinis-admin-ui/src/components/editor/EditorInfoPanel.tsx
new file mode 100644
index 0000000..092b192
--- /dev/null
+++ b/ordinis-admin-ui/src/components/editor/EditorInfoPanel.tsx
@@ -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.
+ *
+ *
Содержимое (top → bottom):
+ *
+ * - Scope badge (PUBLIC/INTERNAL/RESTRICTED)
+ * - ОПИСАНИЕ caption + description text
+ * - BUNDLE / ВЕРСИЯ / ЗАПИСЕЙ / ЛОКАЛИ metadata grid
+ * - СВЯЗИ — → ссылается (outgoing FK names) + ← используют (incoming dict names)
+ *
+ *
+ * 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()
+ for (const dep of refByRaw ?? []) seen.add(dep.sourceDict)
+ return Array.from(seen).sort()
+ }, [refByRaw])
+
+ return (
+
+ )
+}
+
+function Section({
+ label,
+ children,
+}: {
+ label: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+function MetaCell({
+ label,
+ children,
+}: {
+ label: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+const extractOutgoingFk = (
+ schema: import('@/api/client').JsonSchema | undefined,
+): string[] => {
+ if (!schema?.properties) return []
+ const targets = new Set()
+ 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()
+}
diff --git a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx
index 5ce7aa3..a2efc2d 100644
--- a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx
+++ b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx
@@ -88,6 +88,18 @@ export type RecordDrawerProps = {
onDelete?: () => void
/** Show [История] button + handler (только edit mode). */
onHistory?: () => void
+ /** Optional sticky toolbar per handoff line 279: search полей by name +
+ * "только заполненные" toggle + counter. Caller controls state. Pass
+ * undefined чтобы скрыть toolbar (e.g. create mode without filter context). */
+ toolbar?: {
+ search: string
+ onSearchChange: (next: string) => void
+ onlyFilled: boolean
+ onOnlyFilledChange: (next: boolean) => void
+ /** "N / M полей" caption — visible / total. */
+ counterVisible: number
+ counterTotal: number
+ }
children: ReactNode
}
@@ -101,6 +113,7 @@ export function RecordDrawer({
isPending = false,
onDelete,
onHistory,
+ toolbar,
children,
}: RecordDrawerProps) {
const { t } = useTranslation()
@@ -177,10 +190,36 @@ export function RecordDrawer({
+ {/* === TOOLBAR (sticky) per handoff line 279 ===
+ * Optional — caller passes toolbar prop с search/onlyFilled state. */}
+ {toolbar && (
+
+ toolbar.onSearchChange(e.target.value)}
+ placeholder={t('drawer.searchPlaceholder', { defaultValue: 'Поиск полей' })}
+ className="flex-1 h-7 px-2.5 rounded-md border border-line bg-surface-2 text-body text-ink placeholder:text-mute focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
+
+ {toolbar.counterVisible}/{toolbar.counterTotal}
+
+
+ )}
+
{/* === BODY (scrolling) ===
- * Phase 2 здесь будет: sticky toolbar (search + filled-only toggle +
- * counter) → section anchor chips strip → optional left ToC rail в
- * wide mode (CSS grid 180px / 1fr). Пока children — SchemaDrivenForm. */}
+ * Phase B остатки (deferred — требует x-section schema metadata):
+ * - section anchor chips strip над form sections
+ * - wide-mode left ToC rail (180px) в CSS grid 180px/1fr layout */}
{children}
{/* === FOOTER (sticky bottom) === */}
diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
index aaa3d7d..7cdfda3 100644
--- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
+++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
@@ -42,6 +42,7 @@ 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 { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
import { nowIsoLocal } from '@/lib/dates'
@@ -621,15 +622,23 @@ function DictionaryDetail() {
}
/>
- {/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
- {detailQuery.data && (
-
- )}
+ {/* 3-col layout per handoff prototype design/compact.html:
+ [Sidebar (220px global)] [InfoPanel 220px] [main content]
+ Info panel — sticky слева с dict metadata + relations.
+ На
+ {detailQuery.data && (
+
+ )}
- {/* Editor tabs per handoff Stage 3.4:
- Records | Relations | Fields | JSON | Events | History.
- URL-synced через ?tab=. Default — records (records table + filters). */}
- {detailQuery.data && (
+
+ {/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
+ {detailQuery.data && (
+
+ )}
+
+ {/* Editor tabs per handoff Stage 3.4 */}
+ {detailQuery.data && (
@@ -945,6 +954,9 @@ function DictionaryDetail() {
>
)}
{/* === end records tab content === */}
+
+
+ {/* === end 3-col grid === */}
= {
- PUBLIC: 'bg-accent',
- INTERNAL: 'bg-warn',
- RESTRICTED: 'bg-pink',
-}
-
// ===== Route =====
export const Route = createFileRoute('/dictionaries/')({
@@ -181,7 +173,6 @@ function DictionariesPage() {
return out
}, [data])
- const grouped = useMemo(() => groupByBundle(filtered), [filtered])
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
const resetFilters = () => navigate({ search: {} })
@@ -365,24 +356,10 @@ function DictionariesPage() {
)}
) : (
- // Bundle-grouped 2-col card grid
- Array.from(grouped.entries()).map(([bundle, items]) => (
-
-
-
- {bundle}
-
-
- · {items.length} {t('dict.list.records.short')}
-
-
-
- {items.map((d) => (
-
- ))}
-
-
- ))
+ // Row-based table per handoff prototype design/compact.html.
+ // Single flat table (bundle is a column, не group header).
+ // Hover/keyboard клик на строку → navigate к editor.
+
)}
['t']
-const MAX_REFBY_CHIPS = 3
-
/**
* Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может
* указывать через несколько fields — на катаоге показываем как один chip).
@@ -421,99 +396,142 @@ export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => {
return out
}
-const FkChip = ({
- to,
- label,
- dim,
-}: {
- to: string
- label: string
- dim?: boolean
-}) => (
- e.stopPropagation()}
- title={to}
- className={`inline-flex items-center px-[7px] py-[2px] rounded-[4px] font-mono text-[11px] transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
- dim
- ? 'border border-dashed border-line text-mute hover:border-accent/60 hover:text-accent'
- : 'bg-accent-bg text-accent hover:bg-accent hover:text-on-accent'
- }`}
- >
- {label}
-
-)
+// ===== Row table per handoff prototype design/compact.html =====
-const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => {
- // refBy — backend возвращает SchemaDependent[] (cached 5min, parallel
- // queries dedup'ятся TanStack Query'ем). На каталоге фетчим per dict, что на
- // 40 dicts = 40 параллельных GET — bursty первый раз, кешировано далее.
- // outgoing fk → ссылается потребует schemaJson per dict (отдельный followup,
- // backend batch endpoint желателен).
+/**
+ * Catalog list — compact row table per handoff Screen 1 (line 173+).
+ * Columns: name+subtitle / id / bundle / scope / records / → / ← / updated.
+ * Click row → navigate к editor.
+ */
+function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+ |
+ {t('dict.col.title', { defaultValue: 'Название' })}
+ |
+
+ {t('dict.col.id', { defaultValue: 'id' })}
+ |
+
+ {t('dict.col.bundle', { defaultValue: 'bundle' })}
+ |
+
+ {t('dict.col.scope', { defaultValue: 'scope' })}
+ |
+
+ {t('dict.col.records', { defaultValue: 'записей' })}
+ |
+
+ →
+ |
+
+ ←
+ |
+
+ {t('dict.col.updated', { defaultValue: 'изменён' })}
+ |
+
+
+
+ {rows.map((d) => (
+
+ ))}
+
+
+
+ )
+}
+
+function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
+ const navigate = useNavigate({ from: '/dictionaries/' })
const { data: refByRaw } = useDictionaryDependents(d.name)
const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw])
- const hasFkRow = refBy.length > 0
+ const incomingCount = refBy.length
+
+ const updatedLabel = useMemo(() => {
+ const date = new Date(d.updatedAt)
+ return isNaN(date.getTime())
+ ? '—'
+ : date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ })
+ }, [d.updatedAt])
+
+ const handleClick = () => {
+ void navigate({ to: '/dictionaries/$name', params: { name: d.name } })
+ }
+ const handleKey = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleClick()
+ }
+ }
return (
-
- {/* Scope strip 3px вертикальная полоса (handoff spec) */}
-
-
- {/* Body */}
-
-
-
+ {/* name + subtitle (description short) */}
+ |
+
+
+
{d.displayName ?? d.name}
-
- {d.name}
+
{d.approvalRequired && (
{t('dict.list.approval')}
)}
{d.description && (
-
+
{d.description}
)}
- {hasFkRow && (
-
-
- {t('dict.list.fk.usedBy', { count: refBy.length })}
-
- {refBy.slice(0, MAX_REFBY_CHIPS).map((dep) => (
-
- ))}
- {refBy.length > MAX_REFBY_CHIPS && (
-
- +{refBy.length - MAX_REFBY_CHIPS}
-
- )}
-
- )}
-
-
- {/* Right rail: scope badge + version */}
-
+ |
+ {/* id mono */}
+
+ {d.name}
+ |
+ {/* bundle */}
+
+ {d.bundle}
+ |
+ {/* scope badge */}
+
{d.scope}
-
- v{d.schemaVersion}
-
- {typeof d.recordCount === 'number' && (
-
- {t('dict.list.recordCount', { count: d.recordCount })}
-
- )}
-
-
+ |
+ {/* records count */}
+
+ {typeof d.recordCount === 'number' ? d.recordCount : '—'}
+ |
+ {/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */}
+ — |
+ {/* incoming FK count */}
+
+ {incomingCount > 0 ? incomingCount : '—'}
+ |
+ {/* updated */}
+
+ {updatedLabel}
+ |
+
)
}