feat(editor): ExportModal + TimeTravelModal + InfoPanel merge + fixes
UI redesign matching prototype (Ordinis (3) dump): - ExportModal: format (CSV/JSON/Excel/SQL) + scope (all/filtered/selected) + column toggles + encoding/delimiter + preview filename per redesign line 1059-1092. CSV uses backend mutation, JSON client-side from filtered rows, Excel/SQL disabled (backend pending). - TimeTravelModal: full-page compare СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ + quick presets (сейчас/неделя/месяц/год/при создании) + step toggle версия/день + slider + tabs (Что изменилось / Записи на момент / Структура и поля) + "Открыть как readonly" CTA. Replaces inline TimeTravelPicker. - InfoPanel ← используют merge: rich rows с field path + active records count + onClose policy chip (BLOCK/WARN/CASCADE). Inline DictionaryDependentsPanel above records table removed (one source of truth). - Sidebar logo: match redesign/ui-kit.html .brand SVG (outer ring + inner faded ring + orbit dot offset right cx=22.5). ORDINIS Tektur tracking 0.22em + MDM smaller mute 0.32em. - Bg neutralized: #fbf8ee Earth cream → #faf9f5 warm neutral default (per user preference, Earth cream still described in comment как opt-in). - WorkflowBanner: Case 1 Live (approvalRequired=false) returns null — permanent "Опубликовано" banner = noise, status already visible в InfoPanel scope/version/updatedAt. Banner показывается только когда есть actionable state. Critical bug fixes: - fix(CascadeConfirmDialog): infinite render loop "Maximum update depth exceeded". useEffect had cascadeMut (TanStack Query mutation wrapper) в deps — wrapper identity changes every render → effect fires → reset() → store update → render → loop. Extract stable `reset` reference перед useEffect. Симптомы у user: создание/редактирование записи не работали. - fix(semantic-release): add missing peer dep conventional-changelog-conventionalcommits (9.3.1). semantic-release 22+ больше не bundles preset. Без неё release job падал с "Cannot find module 'conventional-changelog-conventionalcommits'".
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
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 { useDictionaryDependents } from '@/api/queries'
|
||||
import type { DictionaryDetail } from '@/api/client'
|
||||
import type { DictionaryDetail, OnCloseAction, SchemaDependent } from '@/api/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
@@ -42,11 +42,7 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
||||
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])
|
||||
const incomingDeps: SchemaDependent[] = useMemo(() => refByRaw ?? [], [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">
|
||||
@@ -127,12 +123,16 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relations */}
|
||||
{(outgoingNames.length > 0 || incomingNames.length > 0) && (
|
||||
{/* Relations — merged inline DictionaryDependentsPanel в InfoPanel.
|
||||
* → ссылается: 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
|
||||
label={t('editor.info.relations', {
|
||||
defaultValue: 'Связи',
|
||||
count: outgoingNames.length + incomingNames.length,
|
||||
count: outgoingNames.length + incomingDeps.length,
|
||||
})}
|
||||
>
|
||||
{outgoingNames.length > 0 && (
|
||||
@@ -155,21 +155,46 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{incomingNames.length > 0 && (
|
||||
{incomingDeps.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}>
|
||||
<ul className="space-y-2">
|
||||
{incomingDeps.map((d) => (
|
||||
<li
|
||||
key={`${d.sourceDict}.${d.sourceField}`}
|
||||
className="rounded-md border border-line bg-surface px-2.5 py-2 space-y-1.5"
|
||||
>
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name }}
|
||||
className="text-mono text-accent hover:underline"
|
||||
params={{ name: d.sourceDict }}
|
||||
className="text-body text-accent hover:underline font-medium truncate block"
|
||||
title={d.sourceDisplayName ?? d.sourceDict}
|
||||
>
|
||||
{name}
|
||||
{d.sourceDisplayName ?? d.sourceDict}
|
||||
</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>
|
||||
))}
|
||||
</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 = (
|
||||
schema: import('@/api/client').JsonSchema | undefined,
|
||||
): 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 { 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 { cn } from '@/lib/utils'
|
||||
|
||||
@@ -11,7 +11,9 @@ import { cn } from '@/lib/utils'
|
||||
* live transition). Используем существующие поля как proxy:
|
||||
* <ul>
|
||||
* <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
|
||||
* (pendingCount > 0). Warn-bg, link to /reviews.</li>
|
||||
* <li><b>Approval enabled empty</b> — approvalRequired=true но нет drafts.
|
||||
@@ -30,28 +32,12 @@ export type WorkflowBannerProps = {
|
||||
|
||||
export function WorkflowBanner({ detail, pendingCount = 0 }: WorkflowBannerProps) {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// === Case 2: Review pending — approval required + drafts queued ===
|
||||
|
||||
Reference in New Issue
Block a user