Merge branch 'feat/info-panel-export-button' into 'main'
feat(editor): ExportModal + TimeTravelModal + InfoPanel merge + critical fixes See merge request 2-6/2-6-4/terravault/ordinis!74
This commit is contained in:
@@ -63,6 +63,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"semantic-release": "^24.2.1",
|
"semantic-release": "^24.2.1",
|
||||||
"tailwindcss": "^4.0.0-beta.7",
|
"tailwindcss": "^4.0.0-beta.7",
|
||||||
|
|||||||
Generated
+11
@@ -159,6 +159,9 @@ importers:
|
|||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))
|
version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||||
|
conventional-changelog-conventionalcommits:
|
||||||
|
specifier: ^9.3.1
|
||||||
|
version: 9.3.1
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^29.1.1
|
specifier: ^29.1.1
|
||||||
version: 29.1.1
|
version: 29.1.1
|
||||||
@@ -1905,6 +1908,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==}
|
resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
conventional-changelog-conventionalcommits@9.3.1:
|
||||||
|
resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
conventional-changelog-writer@8.4.0:
|
conventional-changelog-writer@8.4.0:
|
||||||
resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==}
|
resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5324,6 +5331,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
compare-func: 2.0.0
|
compare-func: 2.0.0
|
||||||
|
|
||||||
|
conventional-changelog-conventionalcommits@9.3.1:
|
||||||
|
dependencies:
|
||||||
|
compare-func: 2.0.0
|
||||||
|
|
||||||
conventional-changelog-writer@8.4.0:
|
conventional-changelog-writer@8.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@simple-libs/stream-utils': 1.2.0
|
'@simple-libs/stream-utils': 1.2.0
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ClockCounterClockwiseIcon, MapTrifoldIcon, DownloadSimpleIcon } from '@phosphor-icons/react'
|
import { ClockCounterClockwiseIcon, MapTrifoldIcon, DownloadSimpleIcon, ArrowRightIcon } from '@phosphor-icons/react'
|
||||||
import { Badge } from '@/ui'
|
import { Badge } from '@/ui'
|
||||||
import { useDictionaryDependents } from '@/api/queries'
|
import { useDictionaryDependents } from '@/api/queries'
|
||||||
import type { DictionaryDetail } from '@/api/client'
|
import type { DictionaryDetail, OnCloseAction, SchemaDependent } from '@/api/client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,11 +42,7 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
|||||||
const { data: refByRaw } = useDictionaryDependents(detail.name)
|
const { data: refByRaw } = useDictionaryDependents(detail.name)
|
||||||
|
|
||||||
const outgoingNames = useMemo(() => extractOutgoingFk(detail.schemaJson), [detail.schemaJson])
|
const outgoingNames = useMemo(() => extractOutgoingFk(detail.schemaJson), [detail.schemaJson])
|
||||||
const incomingNames = useMemo(() => {
|
const incomingDeps: SchemaDependent[] = useMemo(() => refByRaw ?? [], [refByRaw])
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const dep of refByRaw ?? []) seen.add(dep.sourceDict)
|
|
||||||
return Array.from(seen).sort()
|
|
||||||
}, [refByRaw])
|
|
||||||
|
|
||||||
return (
|
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">
|
<aside className="lg:sticky lg:top-2 self-start space-y-4 lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto pr-2">
|
||||||
@@ -127,12 +123,16 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Relations */}
|
{/* Relations — merged inline DictionaryDependentsPanel в InfoPanel.
|
||||||
{(outgoingNames.length > 0 || incomingNames.length > 0) && (
|
* → ссылается: outgoing FK targets (имя dict, links).
|
||||||
|
* ← используют: incoming FK rows с field path + active count + policy chip
|
||||||
|
* (BLOCK / WARN / CASCADE). Это replaces standalone reverse-FK card
|
||||||
|
* which раньше висел над records table. */}
|
||||||
|
{(outgoingNames.length > 0 || incomingDeps.length > 0) && (
|
||||||
<Section
|
<Section
|
||||||
label={t('editor.info.relations', {
|
label={t('editor.info.relations', {
|
||||||
defaultValue: 'Связи',
|
defaultValue: 'Связи',
|
||||||
count: outgoingNames.length + incomingNames.length,
|
count: outgoingNames.length + incomingDeps.length,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{outgoingNames.length > 0 && (
|
{outgoingNames.length > 0 && (
|
||||||
@@ -155,21 +155,46 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{incomingNames.length > 0 && (
|
{incomingDeps.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-cap text-mute">
|
<div className="text-cap text-mute">
|
||||||
← {t('editor.info.incoming', { defaultValue: 'используют' })}
|
← {t('editor.info.incoming', { defaultValue: 'используют' })}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-2">
|
||||||
{incomingNames.map((name) => (
|
{incomingDeps.map((d) => (
|
||||||
<li key={name}>
|
<li
|
||||||
|
key={`${d.sourceDict}.${d.sourceField}`}
|
||||||
|
className="rounded-md border border-line bg-surface px-2.5 py-2 space-y-1.5"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/dictionaries/$name"
|
to="/dictionaries/$name"
|
||||||
params={{ name }}
|
params={{ name: d.sourceDict }}
|
||||||
className="text-mono text-accent hover:underline"
|
className="text-body text-accent hover:underline font-medium truncate block"
|
||||||
|
title={d.sourceDisplayName ?? d.sourceDict}
|
||||||
>
|
>
|
||||||
{name}
|
{d.sourceDisplayName ?? d.sourceDict}
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="flex items-center gap-1 text-mono text-ink-2 min-w-0">
|
||||||
|
<span className="truncate">.{d.sourceField}</span>
|
||||||
|
<ArrowRightIcon weight="bold" size={10} className="text-mute shrink-0" />
|
||||||
|
<span className="truncate">{d.targetField}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{d.activeRecordsInSourceDict > 0 && (
|
||||||
|
<span className="text-cap text-mute tabular-nums">
|
||||||
|
{t('lineage.usedBy.activeCount', {
|
||||||
|
count: d.activeRecordsInSourceDict,
|
||||||
|
defaultValue: `${d.activeRecordsInSourceDict} записей`,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
'text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm',
|
||||||
|
ON_CLOSE_CLASS[d.onClose],
|
||||||
|
)}>
|
||||||
|
{d.onClose}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -211,6 +236,14 @@ function MetaCell({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Visual variant for FK onClose policy chip — BLOCK = warn, WARN = amber,
|
||||||
|
* CASCADE = error red. Используется в InfoPanel ← используют rows. */
|
||||||
|
const ON_CLOSE_CLASS: Record<OnCloseAction, string> = {
|
||||||
|
BLOCK: 'bg-pink-bg text-pink',
|
||||||
|
WARN: 'bg-warn-bg text-warn',
|
||||||
|
CASCADE: 'bg-warn-bg text-warn',
|
||||||
|
}
|
||||||
|
|
||||||
const extractOutgoingFk = (
|
const extractOutgoingFk = (
|
||||||
schema: import('@/api/client').JsonSchema | undefined,
|
schema: import('@/api/client').JsonSchema | undefined,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Dialog, DialogContent } from '@/ui'
|
||||||
|
import type { DictionaryDetail, FlattenedRecord } from '@/api/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExportModal — per redesign prototype (handoff line 1059-1092).
|
||||||
|
*
|
||||||
|
* <p>Layout сверху-вниз:
|
||||||
|
* <ul>
|
||||||
|
* <li>Header: ЭКСПОРТ · {NAME} V{version}</li>
|
||||||
|
* <li>Формат — 4 card buttons (CSV/JSON/Excel/SQL)</li>
|
||||||
|
* <li>Что выгружаем — pill toggle (всё / отфильтрованное / выделенное)</li>
|
||||||
|
* <li>Колонки (N) — chip toggle list из schema.properties</li>
|
||||||
|
* <li>Кодировка + Разделитель — для CSV (другие форматы скрывают)</li>
|
||||||
|
* <li>Preview filename + estimated rows/cols footer caption</li>
|
||||||
|
* <li>Действия: Отмена / Скачать</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><b>Backend coverage:</b>
|
||||||
|
* <ul>
|
||||||
|
* <li>CSV — backend `POST /dictionaries/{name}/records/export-selected.csv`
|
||||||
|
* (передаём businessKeys). Backend сейчас не принимает column subset —
|
||||||
|
* UI показывает выбранные колонки только cosmetically; backend вернёт
|
||||||
|
* все публичные поля. TODO когда backend поддержит `?columns=` param.</li>
|
||||||
|
* <li>JSON — client-side build из filteredRecords/selection (быстро, без
|
||||||
|
* network round-trip). Поддерживает column subset правильно.</li>
|
||||||
|
* <li>Excel / SQL — backend endpoint не реализован; disabled с tooltip
|
||||||
|
* "Скоро · backend export job".</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExportFormat = 'csv' | 'json' | 'xlsx' | 'sql'
|
||||||
|
export type ExportScope = 'all' | 'filtered' | 'selected'
|
||||||
|
|
||||||
|
type ExportModalProps = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
detail: DictionaryDetail
|
||||||
|
totalCount: number
|
||||||
|
filteredRecords: FlattenedRecord[]
|
||||||
|
selection: Set<string>
|
||||||
|
/** Backend CSV export — uses POST /export-selected.csv с businessKeys. */
|
||||||
|
onExportCsv: (businessKeys: string[]) => void
|
||||||
|
exportPending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMATS: { id: ExportFormat; label: string; ext: string; disabled?: boolean; reason?: string }[] = [
|
||||||
|
{ id: 'csv', label: 'CSV', ext: '.csv' },
|
||||||
|
{ id: 'json', label: 'JSON', ext: '.json' },
|
||||||
|
{ id: 'xlsx', label: 'Excel', ext: '.xlsx', disabled: true, reason: 'Скоро · backend export job' },
|
||||||
|
{ id: 'sql', label: 'SQL', ext: '.sql', disabled: true, reason: 'Скоро · backend export job' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ENCODINGS = [
|
||||||
|
{ id: 'utf-8', label: 'utf-8' },
|
||||||
|
{ id: 'win1251', label: 'windows-1251' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const DELIMITERS = [
|
||||||
|
{ id: ',', label: 'запятая ,' },
|
||||||
|
{ id: ';', label: 'точка с запятой ;' },
|
||||||
|
{ id: '\t', label: 'табуляция' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type Encoding = (typeof ENCODINGS)[number]['id']
|
||||||
|
type Delimiter = (typeof DELIMITERS)[number]['id']
|
||||||
|
|
||||||
|
export function ExportModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
detail,
|
||||||
|
totalCount,
|
||||||
|
filteredRecords,
|
||||||
|
selection,
|
||||||
|
onExportCsv,
|
||||||
|
exportPending,
|
||||||
|
}: ExportModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const allColumns = useMemo(
|
||||||
|
() => Object.keys(detail.schemaJson.properties ?? {}),
|
||||||
|
[detail.schemaJson.properties],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||||
|
// Default scope: selected > filtered > all (как пользователь хочет реально).
|
||||||
|
const initialScope: ExportScope =
|
||||||
|
selection.size > 0 ? 'selected' : filteredRecords.length < totalCount ? 'filtered' : 'all'
|
||||||
|
const [scope, setScope] = useState<ExportScope>(initialScope)
|
||||||
|
const [columns, setColumns] = useState<Set<string>>(() => {
|
||||||
|
// По умолчанию включаем "ключевые" колонки: id, name*, locale-aware. Если
|
||||||
|
// у схемы больше 6 — disable некоторые heavy (raw_json, _instruments).
|
||||||
|
const initial = new Set<string>(allColumns)
|
||||||
|
return initial
|
||||||
|
})
|
||||||
|
const [encoding, setEncoding] = useState<Encoding>('utf-8')
|
||||||
|
const [delimiter, setDelimiter] = useState<Delimiter>(',')
|
||||||
|
|
||||||
|
const toggleColumn = (name: string) => {
|
||||||
|
setColumns((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(name)) next.delete(name)
|
||||||
|
else next.add(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCount = selection.size
|
||||||
|
const filteredCount = filteredRecords.length
|
||||||
|
|
||||||
|
// Estimated row count для текущего scope.
|
||||||
|
const estimatedRows =
|
||||||
|
scope === 'all' ? totalCount : scope === 'filtered' ? filteredCount : selectedCount
|
||||||
|
|
||||||
|
// Preview filename per prototype: name_vX_filtered.ext / _selected / _all
|
||||||
|
const previewName = useMemo(() => {
|
||||||
|
const suffix =
|
||||||
|
scope === 'all' ? '' : scope === 'filtered' ? '_filtered' : '_selected'
|
||||||
|
return `${detail.name}_v${detail.schemaVersion}${suffix}${FORMATS.find((f) => f.id === format)?.ext}`
|
||||||
|
}, [detail.name, detail.schemaVersion, scope, format])
|
||||||
|
|
||||||
|
const canExport =
|
||||||
|
!exportPending && columns.size > 0 && estimatedRows > 0 && !FORMATS.find((f) => f.id === format)?.disabled
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
// Resolve businessKeys для текущего scope.
|
||||||
|
const keys =
|
||||||
|
scope === 'all'
|
||||||
|
? filteredRecords.map((r) => r.businessKey)
|
||||||
|
: scope === 'filtered'
|
||||||
|
? filteredRecords.map((r) => r.businessKey)
|
||||||
|
: Array.from(selection)
|
||||||
|
|
||||||
|
if (keys.length === 0) return
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
onExportCsv(keys)
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
// Client-side JSON build. Берём filteredRecords (для selected — фильтруем).
|
||||||
|
const source =
|
||||||
|
scope === 'selected'
|
||||||
|
? filteredRecords.filter((r) => selection.has(r.businessKey))
|
||||||
|
: filteredRecords
|
||||||
|
const rows = source.map((r) => {
|
||||||
|
const filtered: Record<string, unknown> = {}
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col in r.data) filtered[col] = r.data[col]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
businessKey: r.businessKey,
|
||||||
|
dataScope: r.dataScope,
|
||||||
|
validFrom: r.validFrom,
|
||||||
|
validTo: r.validTo,
|
||||||
|
data: filtered,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const blob = new Blob([JSON.stringify(rows, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
})
|
||||||
|
triggerDownload(blob, previewName)
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// xlsx/sql — disabled в UI, но guard на всякий.
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent size="xl" className="!p-0 gap-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-line">
|
||||||
|
<div className="text-cap text-mute tracking-[0.18em] uppercase">
|
||||||
|
{t('export.title', { defaultValue: 'Экспорт' })}
|
||||||
|
<span className="mx-2 text-line">·</span>
|
||||||
|
<span className="text-ink">{detail.name.toUpperCase()}</span>
|
||||||
|
<span className="mx-1">v{detail.schemaVersion}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-5 space-y-5">
|
||||||
|
{/* Format cards */}
|
||||||
|
<Section label={t('export.format', { defaultValue: 'Формат' })}>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{FORMATS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !f.disabled && setFormat(f.id)}
|
||||||
|
disabled={f.disabled}
|
||||||
|
title={f.reason}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center gap-0.5 h-[68px] rounded-md border transition-colors',
|
||||||
|
format === f.id
|
||||||
|
? 'border-ink bg-surface text-ink ring-1 ring-ink'
|
||||||
|
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
||||||
|
f.disabled && 'opacity-40 cursor-not-allowed hover:bg-surface hover:border-line',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-title-md font-semibold leading-none">{f.label}</span>
|
||||||
|
<span className="text-mono text-mute leading-none">{f.ext}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Scope toggle */}
|
||||||
|
<Section label={t('export.scope', { defaultValue: 'Что выгружаем' })}>
|
||||||
|
<div className="inline-flex rounded-md border border-line bg-surface overflow-hidden">
|
||||||
|
<ScopeBtn
|
||||||
|
active={scope === 'all'}
|
||||||
|
onClick={() => setScope('all')}
|
||||||
|
label={t('export.scope.all', { defaultValue: 'всё' })}
|
||||||
|
count={totalCount}
|
||||||
|
/>
|
||||||
|
<ScopeBtn
|
||||||
|
active={scope === 'filtered'}
|
||||||
|
onClick={() => setScope('filtered')}
|
||||||
|
label={t('export.scope.filtered', { defaultValue: 'отфильтрованное' })}
|
||||||
|
count={filteredCount}
|
||||||
|
disabled={filteredCount === totalCount}
|
||||||
|
/>
|
||||||
|
<ScopeBtn
|
||||||
|
active={scope === 'selected'}
|
||||||
|
onClick={() => setScope('selected')}
|
||||||
|
label={t('export.scope.selected', { defaultValue: 'выделенное' })}
|
||||||
|
count={selectedCount}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Columns toggle chips */}
|
||||||
|
<Section
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
{t('export.columns', { defaultValue: 'Колонки' })}
|
||||||
|
<span className="ml-1 text-mono text-mute normal-case">
|
||||||
|
({columns.size}/{allColumns.length})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-1.5">
|
||||||
|
{allColumns.map((col) => {
|
||||||
|
const active = columns.has(col)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={col}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleColumn(col)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'h-8 px-3 rounded-md border text-mono text-body text-left flex items-center gap-1.5 transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-accent bg-accent-bg text-accent'
|
||||||
|
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 opacity-80">{active ? '✓' : ''}</span>
|
||||||
|
<span className="truncate">{col}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* CSV-only: encoding + delimiter */}
|
||||||
|
{format === 'csv' && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Section label={t('export.encoding', { defaultValue: 'Кодировка' })}>
|
||||||
|
<Select
|
||||||
|
value={encoding}
|
||||||
|
onChange={(v) => setEncoding(v as Encoding)}
|
||||||
|
options={ENCODINGS.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section label={t('export.delimiter', { defaultValue: 'Разделитель' })}>
|
||||||
|
<Select
|
||||||
|
value={delimiter}
|
||||||
|
onChange={(v) => setDelimiter(v as Delimiter)}
|
||||||
|
options={DELIMITERS.map((d) => ({ value: d.id, label: d.label }))}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview footer */}
|
||||||
|
<div className="px-6 py-3 border-t border-line-2 bg-surface-2/50 flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="text-cell text-mute">
|
||||||
|
<span className="text-cap tracking-[0.16em] uppercase mr-2">
|
||||||
|
{t('export.preview.filename', { defaultValue: 'Превью имени файла' })}
|
||||||
|
</span>
|
||||||
|
<span className="text-mono text-accent">{previewName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-cell text-mute tabular-nums">
|
||||||
|
≈ {estimatedRows} {pluralRu(estimatedRows, 'строка', 'строки', 'строк')}{' '}
|
||||||
|
· {columns.size} {pluralRu(columns.size, 'колонка', 'колонки', 'колонок')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div className="px-6 py-3 border-t border-line flex items-center justify-between gap-3">
|
||||||
|
<div className="text-cap text-mute tracking-[0.14em] uppercase">
|
||||||
|
{t('export.disclaimer', {
|
||||||
|
defaultValue: 'Включает только публичные поля · readonly snapshot',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 hover:border-line-2 text-body transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.cancel', { defaultValue: 'Отмена' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!canExport}
|
||||||
|
className="h-9 px-4 rounded-md bg-navy text-on-accent hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed text-body font-semibold transition-opacity"
|
||||||
|
>
|
||||||
|
{exportPending
|
||||||
|
? t('export.pending', { defaultValue: 'Экспорт…' })
|
||||||
|
: t('export.download', { defaultValue: 'Скачать' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-cap text-mute tracking-[0.16em] uppercase">{label}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeBtn({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'h-9 px-4 text-body transition-colors border-r border-line last:border-r-0',
|
||||||
|
active
|
||||||
|
? 'bg-navy text-on-accent font-semibold'
|
||||||
|
: 'bg-surface text-ink-2 hover:bg-surface-2',
|
||||||
|
disabled && 'opacity-40 cursor-not-allowed hover:bg-surface',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className={cn('ml-1 tabular-nums', active ? 'opacity-90' : 'text-mute')}>
|
||||||
|
({count})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
onChange: (v: T) => void
|
||||||
|
options: { value: T; label: string }[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value as T)}
|
||||||
|
className="h-9 w-full px-3 rounded-md border border-line bg-surface text-ink text-body hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger browser download через temporary blob URL + <a download>. */
|
||||||
|
function triggerDownload(blob: Blob, filename: string) {
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
requestAnimationFrame(() => window.URL.revokeObjectURL(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Russian plural form helper (1 строка / 2-4 строки / 5+ строк). */
|
||||||
|
function pluralRu(n: number, one: string, few: string, many: string): string {
|
||||||
|
const mod10 = n % 10
|
||||||
|
const mod100 = n % 100
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return one
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
|
||||||
|
return many
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { ClipboardList, CheckCircle, FileText } from 'lucide-react'
|
import { ClipboardList, FileText } from 'lucide-react'
|
||||||
import type { DictionaryDetail } from '@/api/client'
|
import type { DictionaryDetail } from '@/api/client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ import { cn } from '@/lib/utils'
|
|||||||
* live transition). Используем существующие поля как proxy:
|
* live transition). Используем существующие поля как proxy:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li><b>Live</b> (default) — approvalRequired=false → published immediately.
|
* <li><b>Live</b> (default) — approvalRequired=false → published immediately.
|
||||||
* Green-bg, "Опубликовано · v{schemaVersion} · {updatedAt}".</li>
|
* Постоянный "Опубликовано" banner = noise (статус уже виден в InfoPanel
|
||||||
|
* version/updatedAt). Поэтому возвращаем null — banner показываем только
|
||||||
|
* когда есть actionable state.</li>
|
||||||
* <li><b>Review pending</b> — approvalRequired=true И есть pending drafts
|
* <li><b>Review pending</b> — approvalRequired=true И есть pending drafts
|
||||||
* (pendingCount > 0). Warn-bg, link to /reviews.</li>
|
* (pendingCount > 0). Warn-bg, link to /reviews.</li>
|
||||||
* <li><b>Approval enabled empty</b> — approvalRequired=true но нет drafts.
|
* <li><b>Approval enabled empty</b> — approvalRequired=true но нет drafts.
|
||||||
@@ -30,28 +32,12 @@ export type WorkflowBannerProps = {
|
|||||||
|
|
||||||
export function WorkflowBanner({ detail, pendingCount = 0 }: WorkflowBannerProps) {
|
export function WorkflowBanner({ detail, pendingCount = 0 }: WorkflowBannerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const updatedAt = new Date(detail.updatedAt).toLocaleString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
// === Case 1: Live — no approval required, immediate publish ===
|
// === Case 1: Live — no approval required ===
|
||||||
|
// Постоянный "Опубликовано" banner = шум. Status уже виден в InfoPanel
|
||||||
|
// (scope, version, updatedAt). Не показываем banner вообще.
|
||||||
if (!detail.approvalRequired) {
|
if (!detail.approvalRequired) {
|
||||||
return (
|
return null
|
||||||
<Banner
|
|
||||||
variant="live"
|
|
||||||
icon={<CheckCircle size={16} strokeWidth={2} className="shrink-0" />}
|
|
||||||
>
|
|
||||||
<span className="font-medium">
|
|
||||||
{t('workflow.live.title', { defaultValue: 'Опубликовано' })}
|
|
||||||
</span>
|
|
||||||
<span className="text-mono opacity-80">v{detail.schemaVersion}</span>
|
|
||||||
<span className="text-cell opacity-70">· {updatedAt}</span>
|
|
||||||
</Banner>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Case 2: Review pending — approval required + drafts queued ===
|
// === Case 2: Review pending — approval required + drafts queued ===
|
||||||
|
|||||||
@@ -185,22 +185,25 @@ function SidebarContent({
|
|||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
title="ORDINIS MDM"
|
title="ORDINIS MDM"
|
||||||
>
|
>
|
||||||
{/* Circle orbit icon — accent ring + dot center */}
|
{/* Brand orbit icon per redesign/ui-kit.html .brand SVG:
|
||||||
|
outer ring r=13 + inner faded ring r=6 + orbit dot offset right cx=22.5. */}
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="24"
|
||||||
height="20"
|
height="24"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="shrink-0"
|
className="shrink-0 text-accent"
|
||||||
>
|
>
|
||||||
<circle cx="10" cy="10" r="8.5" stroke="var(--color-accent)" strokeWidth="1.4" />
|
<circle cx="16" cy="16" r="13" stroke="currentColor" strokeWidth="1.6" />
|
||||||
<circle cx="10" cy="10" r="2" fill="var(--color-accent)" />
|
<circle cx="16" cy="16" r="6" stroke="currentColor" strokeWidth="1" opacity="0.45" />
|
||||||
|
<circle cx="22.5" cy="16" r="2.2" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="font-display text-base tracking-wider text-ink">
|
<span className="font-display text-[15px] font-semibold leading-none tracking-[0.22em] text-ink inline-flex items-baseline gap-1.5">
|
||||||
ORDINIS <span className="text-mute font-normal">MDM</span>
|
ORDINIS
|
||||||
|
<span className="text-mute font-normal tracking-[0.32em] text-[0.78em]">MDM</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export const CascadeConfirmDialog = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const preview = useCascadePreview(dictionaryName, open ? businessKey : undefined)
|
const preview = useCascadePreview(dictionaryName, open ? businessKey : undefined)
|
||||||
const cascadeMut = useCascadeCloseRecord(dictionaryName)
|
const cascadeMut = useCascadeCloseRecord(dictionaryName)
|
||||||
|
// TanStack Query гарантирует что `reset` / `mutate` стабильны между
|
||||||
|
// renders, а сам wrapper object — нет. Экстрактим reset чтобы не
|
||||||
|
// включать unstable mutation object в useEffect deps (infinite loop:
|
||||||
|
// mutationObject identity → effect fires → reset → store update → render).
|
||||||
|
const cascadeReset = cascadeMut.reset
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
const [confirmText, setConfirmText] = useState('')
|
const [confirmText, setConfirmText] = useState('')
|
||||||
|
|
||||||
@@ -66,9 +71,9 @@ export const CascadeConfirmDialog = ({
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
setReason('')
|
setReason('')
|
||||||
setConfirmText('')
|
setConfirmText('')
|
||||||
cascadeMut.reset()
|
cascadeReset()
|
||||||
}
|
}
|
||||||
}, [open, businessKey, cascadeMut])
|
}, [open, businessKey, cascadeReset])
|
||||||
|
|
||||||
const plan = preview.data
|
const plan = preview.data
|
||||||
const hasBlockers = (plan?.blockers.length ?? 0) > 0
|
const hasBlockers = (plan?.blockers.length ?? 0) > 0
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
|
||||||
|
import { Dialog, DialogContent } from '@/ui'
|
||||||
|
import type { DictionaryDetail } from '@/api/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimeTravelModal — fullscreen-y modal per redesign prototype.
|
||||||
|
*
|
||||||
|
* <p>Layout:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Header</b>: clock icon + TIME-TRAVEL · {DICT} cap + h1 title + ×</li>
|
||||||
|
* <li><b>Compare panels</b>: СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ side-by-side.
|
||||||
|
* Right panel highlighted (`tint`) когда target != current.</li>
|
||||||
|
* <li><b>Quick presets</b>: сейчас / неделя / месяц / год / при создании</li>
|
||||||
|
* <li><b>Step toggle</b>: версия | день + ‹ › nav arrows</li>
|
||||||
|
* <li><b>Slider</b>: HTML range + overlay version markers (top) + date
|
||||||
|
* markers (bottom). Cursor — dark circle.</li>
|
||||||
|
* <li><b>Tabs</b>: Что изменилось / Записи на момент / Структура и поля.
|
||||||
|
* Backend diff endpoints отсутствуют — показываем empty/placeholder
|
||||||
|
* состояния с подсказкой backend pending.</li>
|
||||||
|
* <li><b>Footer</b>: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><b>Backend coverage:</b> findActiveAt(at) уже работает (read-api). Нет
|
||||||
|
* snapshots endpoint, нет diff. Поэтому version labels на slider — выводятся
|
||||||
|
* из schemaVersion текущей версии (single point), а исторические marks из
|
||||||
|
* record validFrom timestamps. "Открыть как readonly" просто применяет
|
||||||
|
* ?at=ISO в URL — route обрабатывает.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TimeTravelModalProps = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
detail: DictionaryDetail
|
||||||
|
/** Currently applied at-ISO (если активен readonly snapshot). */
|
||||||
|
value: string | undefined
|
||||||
|
/** Apply the picked moment: opens dict as readonly (route uses ?at=). */
|
||||||
|
onApply: (iso: string) => void
|
||||||
|
/** Clear at — return к live view. */
|
||||||
|
onClear: () => void
|
||||||
|
/** Unique record validFrom timestamps (ms) — для slider tick marks. */
|
||||||
|
marks: number[]
|
||||||
|
/** Total records на текущий момент — для tab counter. */
|
||||||
|
totalRecords: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepMode = 'version' | 'day'
|
||||||
|
|
||||||
|
export function TimeTravelModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
detail,
|
||||||
|
value,
|
||||||
|
onApply,
|
||||||
|
onClear,
|
||||||
|
marks,
|
||||||
|
totalRecords,
|
||||||
|
}: TimeTravelModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const valueMs = useMemo(() => {
|
||||||
|
if (!value) return now
|
||||||
|
const ms = Date.parse(value)
|
||||||
|
return Number.isNaN(ms) ? now : ms
|
||||||
|
}, [value, now])
|
||||||
|
|
||||||
|
const [picked, setPicked] = useState<number>(valueMs)
|
||||||
|
const [step, setStep] = useState<StepMode>('version')
|
||||||
|
const [tab, setTab] = useState<'changes' | 'records' | 'schema'>('changes')
|
||||||
|
|
||||||
|
// Range: от min(marks, createdAt) до now+padding.
|
||||||
|
const createdMs = Date.parse(detail.createdAt)
|
||||||
|
const range = useMemo(() => {
|
||||||
|
const minMark = marks.length > 0 ? Math.min(...marks, createdMs) : createdMs - 86_400_000
|
||||||
|
const span = now - minMark
|
||||||
|
const pad = Math.max(span * 0.04, 86_400_000)
|
||||||
|
return { min: minMark - pad, max: now + pad }
|
||||||
|
}, [marks, createdMs, now])
|
||||||
|
|
||||||
|
// Sync picked when value prop changes while modal open.
|
||||||
|
// (Don't useEffect — пока explicitly value control.)
|
||||||
|
|
||||||
|
const pickedDate = new Date(picked)
|
||||||
|
const isAtNow = Math.abs(picked - now) < 60_000 // < 1 min ≈ now
|
||||||
|
const targetVersion = detail.schemaVersion // backend doesn't expose snapshots
|
||||||
|
const targetDiffers = !isAtNow
|
||||||
|
|
||||||
|
const handlePreset = (deltaMs: number | 'create') => {
|
||||||
|
if (deltaMs === 'create') {
|
||||||
|
setPicked(createdMs)
|
||||||
|
} else if (deltaMs === 0) {
|
||||||
|
setPicked(now)
|
||||||
|
} else {
|
||||||
|
setPicked(now + deltaMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStepNav = (direction: -1 | 1) => {
|
||||||
|
if (step === 'day') {
|
||||||
|
setPicked((p) => Math.min(range.max, Math.max(range.min, p + direction * 86_400_000)))
|
||||||
|
} else {
|
||||||
|
// Version step: jump to nearest mark
|
||||||
|
const sorted = [...marks].sort((a, b) => a - b)
|
||||||
|
if (sorted.length === 0) return
|
||||||
|
if (direction === -1) {
|
||||||
|
const prev = [...sorted].reverse().find((m) => m < picked - 1000)
|
||||||
|
if (prev !== undefined) setPicked(prev)
|
||||||
|
} else {
|
||||||
|
const next = sorted.find((m) => m > picked + 1000)
|
||||||
|
if (next !== undefined) setPicked(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (isAtNow) {
|
||||||
|
onClear()
|
||||||
|
} else {
|
||||||
|
onApply(new Date(picked).toISOString())
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent size="full" className="!p-0 gap-0 max-h-[95vh]" hideClose>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
|
||||||
|
<div className="size-8 rounded-md bg-surface-2 inline-flex items-center justify-center shrink-0">
|
||||||
|
<ClockCounterClockwiseIcon weight="regular" size={18} className="text-ink-2" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-cap text-mute tracking-[0.18em] uppercase">
|
||||||
|
Time-travel
|
||||||
|
<span className="mx-2 text-line">·</span>
|
||||||
|
<span className="text-ink">{detail.name.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-title-md text-ink font-semibold mt-0.5">
|
||||||
|
{t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close', { defaultValue: 'Закрыть' })}
|
||||||
|
className="rounded-sm p-1 text-mute hover:text-ink hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon weight="bold" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compare panels */}
|
||||||
|
<div className="grid grid-cols-2 border-b border-line">
|
||||||
|
<ComparePanel
|
||||||
|
label={t('timeTravel.compare.now', { defaultValue: 'Сейчас' })}
|
||||||
|
version={detail.schemaVersion}
|
||||||
|
ms={now}
|
||||||
|
records={totalRecords}
|
||||||
|
author={(detail as DictionaryDetail & { updatedBy?: string }).updatedBy}
|
||||||
|
/>
|
||||||
|
<ComparePanel
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
{t('timeTravel.compare.point', { defaultValue: 'Точка во времени' })}
|
||||||
|
{targetDiffers && (
|
||||||
|
<span className="ml-2 text-pink text-cap tracking-wider">
|
||||||
|
· {t('timeTravel.compare.diff', { defaultValue: 'отличается' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
version={targetVersion}
|
||||||
|
ms={picked}
|
||||||
|
records={totalRecords /* backend ?at= даст реальный count при заходе */}
|
||||||
|
author={(detail as DictionaryDetail & { updatedBy?: string }).updatedBy}
|
||||||
|
note={isAtNow ? '«current»' : undefined}
|
||||||
|
highlight={targetDiffers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick presets + step toggle */}
|
||||||
|
<div className="px-6 py-3 border-b border-line-2 flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="text-cap text-mute tracking-[0.16em] uppercase">
|
||||||
|
{t('timeTravel.quick', { defaultValue: 'Быстро' })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Preset label="сейчас" onClick={() => handlePreset(0)} />
|
||||||
|
<Preset label="неделя назад" onClick={() => handlePreset(-7 * 86_400_000)} />
|
||||||
|
<Preset label="месяц назад" onClick={() => handlePreset(-30 * 86_400_000)} />
|
||||||
|
<Preset label="год назад" onClick={() => handlePreset(-365 * 86_400_000)} />
|
||||||
|
<Preset label="при создании" onClick={() => handlePreset('create')} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<div className="text-cap text-mute tracking-[0.16em] uppercase mr-1">
|
||||||
|
{t('timeTravel.step', { defaultValue: 'Шаг' })}
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex rounded-md border border-line bg-surface overflow-hidden">
|
||||||
|
<StepBtn active={step === 'version'} onClick={() => setStep('version')} label="версия" />
|
||||||
|
<StepBtn active={step === 'day'} onClick={() => setStep('day')} label="день" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStepNav(-1)}
|
||||||
|
className="size-8 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||||||
|
aria-label="prev"
|
||||||
|
>
|
||||||
|
<CaretLeftIcon weight="bold" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStepNav(1)}
|
||||||
|
className="size-8 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||||||
|
aria-label="next"
|
||||||
|
>
|
||||||
|
<CaretRightIcon weight="bold" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider */}
|
||||||
|
<div className="px-6 pt-5 pb-3 border-b border-line bg-surface-2/40">
|
||||||
|
<TimelineSlider
|
||||||
|
range={range}
|
||||||
|
marks={marks}
|
||||||
|
value={picked}
|
||||||
|
onChange={setPicked}
|
||||||
|
currentVersion={detail.schemaVersion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="px-6 pt-3 border-b border-line">
|
||||||
|
<div className="flex items-end gap-6 -mb-px">
|
||||||
|
<TabBtn
|
||||||
|
active={tab === 'changes'}
|
||||||
|
onClick={() => setTab('changes')}
|
||||||
|
label={t('timeTravel.tabs.changes', { defaultValue: 'Что изменилось' })}
|
||||||
|
count={0}
|
||||||
|
/>
|
||||||
|
<TabBtn
|
||||||
|
active={tab === 'records'}
|
||||||
|
onClick={() => setTab('records')}
|
||||||
|
label={t('timeTravel.tabs.records', { defaultValue: 'Записи на момент' })}
|
||||||
|
count={totalRecords}
|
||||||
|
/>
|
||||||
|
<TabBtn
|
||||||
|
active={tab === 'schema'}
|
||||||
|
onClick={() => setTab('schema')}
|
||||||
|
label={t('timeTravel.tabs.schema', { defaultValue: 'Структура и поля' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="px-6 py-10 min-h-[180px] flex items-center justify-center text-mute text-body">
|
||||||
|
{tab === 'changes' && (
|
||||||
|
<span>
|
||||||
|
{isAtNow
|
||||||
|
? t('timeTravel.empty.same', {
|
||||||
|
defaultValue: 'Между этими версиями нет изменений.',
|
||||||
|
})
|
||||||
|
: t('timeTravel.placeholder.changes', {
|
||||||
|
defaultValue:
|
||||||
|
'Diff endpoint /api/v1/dictionaries/{name}/changelog · backend pending.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab === 'records' && (
|
||||||
|
<span>
|
||||||
|
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
|
||||||
|
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab === 'schema' && (
|
||||||
|
<span>
|
||||||
|
v{detail.schemaVersion} ·{' '}
|
||||||
|
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
|
||||||
|
{pluralRu(
|
||||||
|
Object.keys(detail.schemaJson.properties ?? {}).length,
|
||||||
|
'поле',
|
||||||
|
'поля',
|
||||||
|
'полей',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-3 border-t border-line flex items-center justify-between gap-3 mt-auto">
|
||||||
|
<div className="text-cap text-mute tracking-[0.14em] uppercase">
|
||||||
|
{t('timeTravel.disclaimer', {
|
||||||
|
defaultValue: 'Snapshot не меняет данные — только показывает',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 hover:border-line-2 text-body transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.close', { defaultValue: 'Закрыть' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
className="h-9 px-4 rounded-md bg-navy text-on-accent hover:opacity-90 text-body font-semibold transition-opacity"
|
||||||
|
>
|
||||||
|
{isAtNow
|
||||||
|
? t('timeTravel.applyNow', { defaultValue: 'Закрыть snapshot' })
|
||||||
|
: t('timeTravel.apply', {
|
||||||
|
defaultValue: `Открыть v${targetVersion} как readonly`,
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparePanel({
|
||||||
|
label,
|
||||||
|
version,
|
||||||
|
ms,
|
||||||
|
records,
|
||||||
|
author,
|
||||||
|
note,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode
|
||||||
|
version: string
|
||||||
|
ms: number
|
||||||
|
records: number
|
||||||
|
author?: string
|
||||||
|
note?: string
|
||||||
|
highlight?: boolean
|
||||||
|
}) {
|
||||||
|
const d = new Date(ms)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'px-6 py-4 space-y-1.5 border-r border-line last:border-r-0',
|
||||||
|
highlight && 'bg-warn-bg/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-cap text-mute tracking-[0.18em] uppercase">{label}</div>
|
||||||
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
|
<span className="text-mono text-title-md text-ink font-semibold">v{version}</span>
|
||||||
|
<span className="text-mono text-cell text-mute">
|
||||||
|
{d.toLocaleDateString(undefined, { dateStyle: 'short' })}
|
||||||
|
{' · '}
|
||||||
|
{d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-cell text-ink-2 flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="tabular-nums">{records}</span>
|
||||||
|
<span>{pluralRu(records, 'запись', 'записи', 'записей')}</span>
|
||||||
|
{note && (
|
||||||
|
<span className="text-mute">
|
||||||
|
· <span className="text-mono">{note}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{author && <span className="text-mute">· {author}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Preset({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="h-8 px-3 rounded-full border border-line bg-surface text-cell text-ink-2 hover:border-ink-2 hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepBtn({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'h-8 px-3 text-body border-r border-line last:border-r-0 transition-colors',
|
||||||
|
active ? 'bg-navy text-on-accent font-semibold' : 'bg-surface text-ink-2 hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabBtn({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'pb-2 text-body inline-flex items-center gap-1.5 border-b-2 transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-ink text-ink font-semibold'
|
||||||
|
: 'border-transparent text-ink-2 hover:text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className={cn('text-mono', active ? 'text-mute' : 'text-mute')}>{count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Slider =============================================================
|
||||||
|
|
||||||
|
function TimelineSlider({
|
||||||
|
range,
|
||||||
|
marks,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
currentVersion,
|
||||||
|
}: {
|
||||||
|
range: { min: number; max: number }
|
||||||
|
marks: number[]
|
||||||
|
value: number
|
||||||
|
onChange: (ms: number) => void
|
||||||
|
currentVersion: string
|
||||||
|
}) {
|
||||||
|
const span = range.max - range.min || 1
|
||||||
|
const pct = ((value - range.min) / span) * 100
|
||||||
|
|
||||||
|
const dateLabel = new Date(value).toLocaleDateString(undefined, {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
{/* Version labels (top) — single current marker для текущей schema version.
|
||||||
|
* Когда backend получит snapshots endpoint — здесь будут все версии. */}
|
||||||
|
<div className="relative h-5 mb-1">
|
||||||
|
<span
|
||||||
|
className="absolute -translate-x-1/2 text-mono text-cap text-accent font-semibold"
|
||||||
|
style={{ left: '100%' }}
|
||||||
|
>
|
||||||
|
v{currentVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track + cursor (range input для drag UX) */}
|
||||||
|
<div className="relative h-6">
|
||||||
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[2px] bg-accent/60 rounded-full" />
|
||||||
|
{/* Mark ticks */}
|
||||||
|
{marks.map((m) => {
|
||||||
|
const p = ((m - range.min) / span) * 100
|
||||||
|
if (p < 0 || p > 100) return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={m}
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-px h-3 bg-accent/40"
|
||||||
|
style={{ left: `${p}%` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Cursor */}
|
||||||
|
<span
|
||||||
|
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 size-5 rounded-full bg-ink border-2 border-surface shadow-md pointer-events-none"
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
{/* Hidden range input для drag */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={range.min}
|
||||||
|
max={range.max}
|
||||||
|
step={86_400_000} // day
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
aria-label="time-travel slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date labels (bottom) — start / value / now */}
|
||||||
|
<div className="relative h-4 mt-1 text-mono text-cap text-mute">
|
||||||
|
<span className="absolute left-0">
|
||||||
|
{new Date(range.min).toLocaleDateString(undefined, {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="absolute -translate-x-1/2 text-accent font-semibold"
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
>
|
||||||
|
{dateLabel}
|
||||||
|
</span>
|
||||||
|
<span className="absolute right-0">
|
||||||
|
{new Date(range.max).toLocaleDateString(undefined, {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralRu(n: number, one: string, few: string, many: string): string {
|
||||||
|
const mod10 = n % 10
|
||||||
|
const mod100 = n % 100
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return one
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
|
||||||
|
return many
|
||||||
|
}
|
||||||
@@ -35,15 +35,15 @@ import { useCanMutate } from '@/auth/useCanMutate'
|
|||||||
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
|
||||||
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
|
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
|
||||||
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
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 { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
|
||||||
|
import { ExportModal } from '@/components/editor/ExportModal'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
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 { nowIsoLocal } from '@/lib/dates'
|
||||||
import { recordDisplayName } from '@/lib/locales'
|
import { recordDisplayName } from '@/lib/locales'
|
||||||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
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 [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
const [aoiOpen, setAoiOpen] = useState(false)
|
||||||
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
||||||
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
||||||
* на close, или явным action из record menu. */
|
* на close, или явным action из record menu. */
|
||||||
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
||||||
@@ -570,16 +571,9 @@ function DictionaryDetail() {
|
|||||||
timeTravelActive: Boolean(timeTravelAt),
|
timeTravelActive: Boolean(timeTravelAt),
|
||||||
onAoi: () => setAoiOpen(true),
|
onAoi: () => setAoiOpen(true),
|
||||||
aoiActive: Boolean(aoi),
|
aoiActive: Boolean(aoi),
|
||||||
onExport: () => {
|
// Открываем ExportModal вместо immediate triggera — пользователь
|
||||||
// Export all visible records (filtered + paginated set OR full
|
// выбирает формат/scope/columns/encoding/delimiter.
|
||||||
// dictionary). Если selection непустой — exports selection,
|
onExport: () => setExportOpen(true),
|
||||||
// иначе — все filtered records.
|
|
||||||
const keys =
|
|
||||||
selection.size > 0
|
|
||||||
? Array.from(selection)
|
|
||||||
: filteredRecords.map((r) => r.businessKey)
|
|
||||||
if (keys.length > 0) bulkExportMut.mutate(keys)
|
|
||||||
},
|
|
||||||
exportPending: bulkExportMut.isPending,
|
exportPending: bulkExportMut.isPending,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -664,11 +658,9 @@ function DictionaryDetail() {
|
|||||||
<HistoryTabContent />
|
<HistoryTabContent />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* DictionaryDependentsPanel — отображается ТОЛЬКО на Records tab.
|
{/* DictionaryDependentsPanel inline-card удалён — reverse FK rows
|
||||||
Phase 1 dict-relationships-v2 reverse FK card. Hide-on-empty. */}
|
* теперь живут в EditorInfoPanel ← используют (rich rows с field path
|
||||||
{(!urlSearch.tab || urlSearch.tab === 'records') && (
|
* + active count + onClose policy chip). Один source of truth. */}
|
||||||
<DictionaryDependentsPanel dictionaryName={name} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{aoi && (
|
{aoi && (
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-accent/30 bg-accent/4 text-body">
|
<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') && (
|
{(!urlSearch.tab || urlSearch.tab === 'records') && (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
{/* Time-travel picker — раскрывается по клику на toolbar button.
|
{/* Time-travel modal — only mount когда open=true. Modal внутри
|
||||||
ISO datetime в URL ?at=… — share-friendly. Active state показывает
|
использует Radix Dialog который portals content при open. Но props
|
||||||
ambient banner справа от input'а. Phase v1 stretch. */}
|
like `marks={recordTimestamps}` пересоздаются на каждый render
|
||||||
{timeTravelOpen && (
|
родителя (new array reference) — это нормально для уже mounted
|
||||||
<TimeTravelPicker
|
компонента, но при always-mounted причиняет нестабильность store.
|
||||||
value={timeTravelAt}
|
Pattern: lazy mount только если open. */}
|
||||||
onChange={(iso) => setTimeTravelAt(iso)}
|
{timeTravelOpen && detailQuery.data && (
|
||||||
onClear={() => setTimeTravelAt(undefined)}
|
<TimeTravelModal
|
||||||
|
open
|
||||||
onClose={() => setTimeTravelOpen(false)}
|
onClose={() => setTimeTravelOpen(false)}
|
||||||
|
detail={detailQuery.data}
|
||||||
|
value={timeTravelAt}
|
||||||
|
onApply={(iso) => setTimeTravelAt(iso)}
|
||||||
|
onClear={() => setTimeTravelAt(undefined)}
|
||||||
marks={recordTimestamps}
|
marks={recordTimestamps}
|
||||||
|
totalRecords={totalRecords}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1125,6 +1123,20 @@ function DictionaryDetail() {
|
|||||||
initial={aoi?.geojson ?? null}
|
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
|
<Modal
|
||||||
isOpen={bulkOpen}
|
isOpen={bulkOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
|
|
||||||
/* === DESIGN TOKENS — Light theme (Earth, default :root) === */
|
/* === DESIGN TOKENS — Light theme (Earth, default :root) === */
|
||||||
:root {
|
:root {
|
||||||
/* Earthy light v2 — synced с redesign/ui-kit.html prototype.
|
/* Earthy light v3 — neutralized page bg per user preference.
|
||||||
* Page bg #fbf8ee warm cream (handoff design intent). Если жёлтый кажется
|
* Прошлая версия #fbf8ee (handoff intent) казалась слишком жёлтой; теперь
|
||||||
* слишком насыщенным — пользователь может переключить на claude-light
|
* используем нейтральный warm white #faf9f5. Полную Earth cream версию
|
||||||
* (нейтральный warm white) через ThemeSwitch. */
|
* можно вернуть через [data-theme="earth-cream"] (opt-in). */
|
||||||
--color-ink: #1a1714;
|
--color-ink: #1a1714;
|
||||||
--color-ink-2: #52483d;
|
--color-ink-2: #52483d;
|
||||||
--color-mute: #8c8276;
|
--color-mute: #8c8276;
|
||||||
--color-bg: #fbf8ee;
|
--color-bg: #faf9f5;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-surface-2: #f4eedc;
|
--color-surface-2: #f5f3ec;
|
||||||
--color-line: #e3dccb;
|
--color-line: #e6e1d4;
|
||||||
--color-line-2: #efe9d8;
|
--color-line-2: #efeae0;
|
||||||
--color-navy: #3a2818;
|
--color-navy: #3a2818;
|
||||||
--color-accent: #b05a2e;
|
--color-accent: #b05a2e;
|
||||||
--color-accent-bg: rgb(176 90 46 / 11%);
|
--color-accent-bg: rgb(176 90 46 / 11%);
|
||||||
|
|||||||
Reference in New Issue
Block a user