Merge branch 'feat/editor-info-panel' into 'main'
feat(editor): info panel + 3-col layout per handoff prototype See merge request 2-6/2-6-4/terravault/ordinis!63
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
@@ -88,6 +88,18 @@ export type RecordDrawerProps = {
|
|||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
/** Show [История] button + handler (только edit mode). */
|
/** Show [История] button + handler (только edit mode). */
|
||||||
onHistory?: () => void
|
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
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +113,7 @@ export function RecordDrawer({
|
|||||||
isPending = false,
|
isPending = false,
|
||||||
onDelete,
|
onDelete,
|
||||||
onHistory,
|
onHistory,
|
||||||
|
toolbar,
|
||||||
children,
|
children,
|
||||||
}: RecordDrawerProps) {
|
}: RecordDrawerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -177,10 +190,36 @@ export function RecordDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* === TOOLBAR (sticky) per handoff line 279 ===
|
||||||
|
* Optional — caller passes toolbar prop с search/onlyFilled state. */}
|
||||||
|
{toolbar && (
|
||||||
|
<div className="shrink-0 border-b border-line-2 px-5 py-2 flex items-center gap-3 bg-surface">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={toolbar.search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-cell text-ink-2 select-none cursor-pointer shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolbar.onlyFilled}
|
||||||
|
onChange={(e) => toolbar.onOnlyFilledChange(e.target.checked)}
|
||||||
|
className="size-3.5 rounded-sm border-line accent-accent"
|
||||||
|
/>
|
||||||
|
{t('drawer.onlyFilled', { defaultValue: 'только заполненные' })}
|
||||||
|
</label>
|
||||||
|
<span className="text-mono text-mute tabular-nums shrink-0">
|
||||||
|
{toolbar.counterVisible}/{toolbar.counterTotal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* === BODY (scrolling) ===
|
{/* === BODY (scrolling) ===
|
||||||
* Phase 2 здесь будет: sticky toolbar (search + filled-only toggle +
|
* Phase B остатки (deferred — требует x-section schema metadata):
|
||||||
* counter) → section anchor chips strip → optional left ToC rail в
|
* - section anchor chips strip над form sections
|
||||||
* wide mode (CSS grid 180px / 1fr). Пока children — SchemaDrivenForm. */}
|
* - wide-mode left ToC rail (180px) в CSS grid 180px/1fr layout */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
|
||||||
{/* === FOOTER (sticky bottom) === */}
|
{/* === FOOTER (sticky bottom) === */}
|
||||||
|
|||||||
@@ -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,15 +622,23 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
|
{/* 3-col layout per handoff prototype design/compact.html:
|
||||||
{detailQuery.data && (
|
[Sidebar (220px global)] [InfoPanel 220px] [main content]
|
||||||
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
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} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Editor tabs per handoff Stage 3.4:
|
<div className="min-w-0 space-y-4">
|
||||||
Records | Relations | Fields | JSON | Events | History.
|
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
|
||||||
URL-synced через ?tab=. Default — records (records table + filters). */}
|
{detailQuery.data && (
|
||||||
{detailQuery.data && (
|
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor tabs per handoff Stage 3.4 */}
|
||||||
|
{detailQuery.data && (
|
||||||
<EditorTabBar
|
<EditorTabBar
|
||||||
active={urlSearch.tab ?? 'records'}
|
active={urlSearch.tab ?? 'records'}
|
||||||
onChange={(tab) =>
|
onChange={(tab) =>
|
||||||
@@ -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'}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* (followup), сейчас рендерится только если backend начнёт возвращать в DictionaryDefinition.
|
* (followup), сейчас рендерится только если backend начнёт возвращать в DictionaryDefinition.
|
||||||
*/
|
*/
|
||||||
import { useDeferredValue, useMemo, useState } from 'react'
|
import { useDeferredValue, useMemo, useState } from 'react'
|
||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui'
|
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui'
|
||||||
import { PlusIcon } from '@phosphor-icons/react'
|
import { PlusIcon } from '@phosphor-icons/react'
|
||||||
@@ -83,14 +83,6 @@ export const groupByBundle = (
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope strip color — vertical 3px на левом крае карточки.
|
|
||||||
// Использует design handoff tokens — auto-switch в dark mode.
|
|
||||||
const SCOPE_STRIP: Record<DataScope, string> = {
|
|
||||||
PUBLIC: 'bg-accent',
|
|
||||||
INTERNAL: 'bg-warn',
|
|
||||||
RESTRICTED: 'bg-pink',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Route =====
|
// ===== Route =====
|
||||||
|
|
||||||
export const Route = createFileRoute('/dictionaries/')({
|
export const Route = createFileRoute('/dictionaries/')({
|
||||||
@@ -181,7 +173,6 @@ function DictionariesPage() {
|
|||||||
return out
|
return out
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const grouped = useMemo(() => groupByBundle(filtered), [filtered])
|
|
||||||
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
|
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
|
||||||
const resetFilters = () => navigate({ search: {} })
|
const resetFilters = () => navigate({ search: {} })
|
||||||
|
|
||||||
@@ -365,24 +356,10 @@ function DictionariesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Bundle-grouped 2-col card grid
|
// Row-based table per handoff prototype design/compact.html.
|
||||||
Array.from(grouped.entries()).map(([bundle, items]) => (
|
// Single flat table (bundle is a column, не group header).
|
||||||
<section key={bundle} className="space-y-3">
|
// Hover/keyboard клик на строку → navigate к editor.
|
||||||
<h2 className="flex items-baseline gap-3 pb-2 border-b border-line">
|
<DictionaryListTable rows={filtered} />
|
||||||
<span className="text-body uppercase tracking-[0.10em] text-accent font-mono font-medium">
|
|
||||||
{bundle}
|
|
||||||
</span>
|
|
||||||
<span className="text-cap text-mute">
|
|
||||||
· {items.length} {t('dict.list.records.short')}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{items.map((d) => (
|
|
||||||
<DictCard key={d.id} d={d} t={t} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DictionaryEditorDialog
|
<DictionaryEditorDialog
|
||||||
@@ -398,12 +375,10 @@ function DictionariesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Card =====
|
// ===== Helpers =====
|
||||||
|
|
||||||
type TFunc = ReturnType<typeof useTranslation>['t']
|
type TFunc = ReturnType<typeof useTranslation>['t']
|
||||||
|
|
||||||
const MAX_REFBY_CHIPS = 3
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может
|
* Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может
|
||||||
* указывать через несколько fields — на катаоге показываем как один chip).
|
* указывать через несколько fields — на катаоге показываем как один chip).
|
||||||
@@ -421,99 +396,142 @@ export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
const FkChip = ({
|
// ===== Row table per handoff prototype design/compact.html =====
|
||||||
to,
|
|
||||||
label,
|
|
||||||
dim,
|
|
||||||
}: {
|
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
dim?: boolean
|
|
||||||
}) => (
|
|
||||||
<Link
|
|
||||||
to="/dictionaries/$name"
|
|
||||||
params={{ name: to }}
|
|
||||||
onClick={(e) => 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}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => {
|
/**
|
||||||
// refBy — backend возвращает SchemaDependent[] (cached 5min, parallel
|
* Catalog list — compact row table per handoff Screen 1 (line 173+).
|
||||||
// queries dedup'ятся TanStack Query'ем). На каталоге фетчим per dict, что на
|
* Columns: name+subtitle / id / bundle / scope / records / → / ← / updated.
|
||||||
// 40 dicts = 40 параллельных GET — bursty первый раз, кешировано далее.
|
* Click row → navigate к editor.
|
||||||
// outgoing fk → ссылается потребует schemaJson per dict (отдельный followup,
|
*/
|
||||||
// backend batch endpoint желателен).
|
function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-line overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-surface-2 border-b border-line">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="text-cap text-mute text-left px-3 py-2">
|
||||||
|
{t('dict.col.title', { defaultValue: 'Название' })}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden md:table-cell">
|
||||||
|
{t('dict.col.id', { defaultValue: 'id' })}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden lg:table-cell">
|
||||||
|
{t('dict.col.bundle', { defaultValue: 'bundle' })}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden sm:table-cell">
|
||||||
|
{t('dict.col.scope', { defaultValue: 'scope' })}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="text-cap text-mute text-right px-3 py-2 hidden md:table-cell">
|
||||||
|
{t('dict.col.records', { defaultValue: 'записей' })}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
|
||||||
|
title={t('dict.col.outgoingFk', { defaultValue: 'Ссылается (outgoing FK)' })}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
|
||||||
|
title={t('dict.col.incomingFk', { defaultValue: 'Используют (incoming FK)' })}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden xl:table-cell">
|
||||||
|
{t('dict.col.updated', { defaultValue: 'изменён' })}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((d) => (
|
||||||
|
<DictRow key={d.id} d={d} t={t} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
|
||||||
|
const navigate = useNavigate({ from: '/dictionaries/' })
|
||||||
const { data: refByRaw } = useDictionaryDependents(d.name)
|
const { data: refByRaw } = useDictionaryDependents(d.name)
|
||||||
const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw])
|
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 (
|
return (
|
||||||
<Link
|
<tr
|
||||||
to="/dictionaries/$name"
|
role="link"
|
||||||
params={{ name: d.name }}
|
tabIndex={0}
|
||||||
className="grid grid-cols-[3px_1fr_auto] bg-surface border border-line rounded-lg overflow-hidden transition-all hover:-translate-y-px hover:shadow-md hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
className="border-b border-line-2 last:border-b-0 cursor-pointer hover:bg-surface-2/40 focus-visible:outline-none focus-visible:bg-accent-bg/30 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Scope strip 3px вертикальная полоса (handoff spec) */}
|
{/* name + subtitle (description short) */}
|
||||||
<span className={SCOPE_STRIP[d.scope]} aria-hidden="true" />
|
<td className="px-3 py-2 align-top min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* Body */}
|
<span className={`inline-block size-1.5 rounded-full shrink-0 ${SCOPE_DOT[d.scope]}`} aria-hidden />
|
||||||
<div className="px-3.5 py-3 min-w-0">
|
<span className="text-body font-medium text-ink truncate">
|
||||||
<div className="flex items-baseline gap-2.5 flex-wrap">
|
|
||||||
<h3 className="text-title-md text-navy truncate">
|
|
||||||
{d.displayName ?? d.name}
|
{d.displayName ?? d.name}
|
||||||
</h3>
|
</span>
|
||||||
<span className="text-mono text-mute truncate">{d.name}</span>
|
|
||||||
{d.approvalRequired && (
|
{d.approvalRequired && (
|
||||||
<Badge variant="warning">{t('dict.list.approval')}</Badge>
|
<Badge variant="warning">{t('dict.list.approval')}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{d.description && (
|
{d.description && (
|
||||||
<p className="text-[12px] text-ink-2 mt-1 leading-snug line-clamp-2">
|
<p className="text-cell text-mute mt-0.5 truncate max-w-md">
|
||||||
{d.description}
|
{d.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{hasFkRow && (
|
</td>
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-2.5">
|
{/* id mono */}
|
||||||
<span className="text-cap font-display text-mute mr-1">
|
<td className="px-3 py-2 hidden md:table-cell">
|
||||||
{t('dict.list.fk.usedBy', { count: refBy.length })}
|
<span className="text-mono text-ink-2">{d.name}</span>
|
||||||
</span>
|
</td>
|
||||||
{refBy.slice(0, MAX_REFBY_CHIPS).map((dep) => (
|
{/* bundle */}
|
||||||
<FkChip
|
<td className="px-3 py-2 hidden lg:table-cell">
|
||||||
key={dep.sourceDict}
|
<span className="text-cap text-mute">{d.bundle}</span>
|
||||||
to={dep.sourceDict}
|
</td>
|
||||||
label={dep.sourceDisplayName ?? dep.sourceDict}
|
{/* scope badge */}
|
||||||
dim
|
<td className="px-3 py-2 hidden sm:table-cell">
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{refBy.length > MAX_REFBY_CHIPS && (
|
|
||||||
<span className="text-mono text-mute">
|
|
||||||
+{refBy.length - MAX_REFBY_CHIPS}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right rail: scope badge + version */}
|
|
||||||
<div className="px-3.5 py-3 flex flex-col items-end gap-1.5 shrink-0">
|
|
||||||
<Badge variant="info">{d.scope}</Badge>
|
<Badge variant="info">{d.scope}</Badge>
|
||||||
<span className="text-mono text-mute whitespace-nowrap">
|
</td>
|
||||||
v{d.schemaVersion}
|
{/* records count */}
|
||||||
</span>
|
<td className="px-3 py-2 text-right tabular-nums text-mono text-ink-2 hidden md:table-cell">
|
||||||
{typeof d.recordCount === 'number' && (
|
{typeof d.recordCount === 'number' ? d.recordCount : '—'}
|
||||||
<span className="text-cap font-display text-mute/80 whitespace-nowrap">
|
</td>
|
||||||
{t('dict.list.recordCount', { count: d.recordCount })}
|
{/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */}
|
||||||
</span>
|
<td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell">—</td>
|
||||||
)}
|
{/* incoming FK count */}
|
||||||
</div>
|
<td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell">
|
||||||
</Link>
|
{incomingCount > 0 ? incomingCount : '—'}
|
||||||
|
</td>
|
||||||
|
{/* updated */}
|
||||||
|
<td className="px-3 py-2 text-mono text-mute tabular-nums hidden xl:table-cell whitespace-nowrap">
|
||||||
|
{updatedLabel}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user