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): + *

+ * + *

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 ( +
+
{label}
+ {children} +
+ ) +} + +function MetaCell({ + label, + children, +}: { + label: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +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 ( +
+ + + + + + + + + + + + + + + {rows.map((d) => ( + + ))} + +
+ {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: 'изменён' })} +
+
+ ) +} + +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) */} -