fix(admin-ui): v1.1.1 patch — version routing + guest mode + timeline UX
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
type RecordsFilter,
|
||||
} from '@/api/queries'
|
||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
|
||||
import { useCanMutate } from '@/auth/useCanMutate'
|
||||
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||
@@ -39,7 +40,8 @@ import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
|
||||
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||
import { nowIsoLocal, toDateTimeLocalInput, fromDateTimeLocalInput } from '@/lib/dates'
|
||||
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
|
||||
import { nowIsoLocal } from '@/lib/dates'
|
||||
import { recordDisplayName } from '@/lib/locales'
|
||||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||
|
||||
@@ -103,6 +105,7 @@ function DictionaryDetail() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { t } = useTranslation()
|
||||
const detailQuery = useDictionaryDetail(name)
|
||||
const canMutate = useCanMutate()
|
||||
|
||||
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
|
||||
// только в local state (не сериализуем в URL — слишком длинно).
|
||||
@@ -523,24 +526,28 @@ function DictionaryDetail() {
|
||||
>
|
||||
{t('timeTravel.button')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<GearIcon weight="bold" size={16} />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setSchemaEditOpen(true)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t('schema.action.editSchema')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setEdit({ kind: 'create' })}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t('dict.action.create')}
|
||||
</Button>
|
||||
{canMutate && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<GearIcon weight="bold" size={16} />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setSchemaEditOpen(true)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t('schema.action.editSchema')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setEdit({ kind: 'create' })}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t('dict.action.create')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -612,50 +619,12 @@ function DictionaryDetail() {
|
||||
ISO datetime в URL ?at=… — share-friendly. Active state показывает
|
||||
ambient banner справа от input'а. Phase v1 stretch. */}
|
||||
{timeTravelOpen && (
|
||||
<div className="flex flex-wrap items-center gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 text-sm">
|
||||
<span className="text-xs text-carbon/70 font-secondary uppercase tracking-label">
|
||||
{t('timeTravel.label')}
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="px-2 py-1 rounded-sm border border-regolith bg-white text-2xs font-mono"
|
||||
// datetime-local требует local time без offset. toISOString() отдаёт
|
||||
// UTC → у пользователя в +03:00 picker показывал значение на 3ч
|
||||
// раньше выбранного, и на каждом редактировании round-trip drift'ил
|
||||
// время. toDateTimeLocalInput / fromDateTimeLocalInput сохраняют
|
||||
// wall-clock время через формат с явным local-TZ offset.
|
||||
value={toDateTimeLocalInput(timeTravelAt)}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setTimeTravelAt(undefined)
|
||||
return
|
||||
}
|
||||
setTimeTravelAt(fromDateTimeLocalInput(e.target.value))
|
||||
}}
|
||||
aria-label={t('timeTravel.label')}
|
||||
/>
|
||||
{timeTravelAt && (
|
||||
<>
|
||||
<span className="text-2xs text-carbon/70 font-mono">
|
||||
{new Date(timeTravelAt).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-ultramarain hover:underline"
|
||||
onClick={() => setTimeTravelAt(undefined)}
|
||||
>
|
||||
{t('timeTravel.clear')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-carbon/60 hover:underline ml-auto"
|
||||
onClick={() => setTimeTravelOpen(false)}
|
||||
>
|
||||
{t('timeTravel.close')}
|
||||
</button>
|
||||
</div>
|
||||
<TimeTravelPicker
|
||||
value={timeTravelAt}
|
||||
onChange={(iso) => setTimeTravelAt(iso)}
|
||||
onClear={() => setTimeTravelAt(undefined)}
|
||||
onClose={() => setTimeTravelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
|
||||
@@ -745,7 +714,7 @@ function DictionaryDetail() {
|
||||
<EmptyState title={t('dict.filter.noMatches')} />
|
||||
) : (
|
||||
<>
|
||||
{selection.size > 0 && (
|
||||
{canMutate && selection.size > 0 && (
|
||||
<BulkSelectionToolbar
|
||||
count={selection.size}
|
||||
totalVisible={filteredCount}
|
||||
@@ -759,14 +728,16 @@ function DictionaryDetail() {
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
aria-label={t('dict.bulk.selectAll')}
|
||||
checked={allVisibleSelected}
|
||||
ref={selectAllRef}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
{canMutate && (
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
aria-label={t('dict.bulk.selectAll')}
|
||||
checked={allVisibleSelected}
|
||||
ref={selectAllRef}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
||||
<TableHeaderCell>name / code</TableHeaderCell>
|
||||
{extraColumns.map((c) => (
|
||||
@@ -780,13 +751,15 @@ function DictionaryDetail() {
|
||||
<TableBody>
|
||||
{visibleRecords.map((r) => (
|
||||
<TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
|
||||
checked={selection.has(r.businessKey)}
|
||||
onChange={() => toggleSelect(r.businessKey)}
|
||||
/>
|
||||
</TableCell>
|
||||
{canMutate && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
|
||||
checked={selection.has(r.businessKey)}
|
||||
onChange={() => toggleSelect(r.businessKey)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-2xs">{r.businessKey}</span>
|
||||
@@ -844,18 +817,22 @@ function DictionaryDetail() {
|
||||
icon={<ClockCounterClockwiseIcon weight="regular" />}
|
||||
onClick={() => setHistoryKey(r.businessKey)}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('dict.action.edit')}
|
||||
variant="default"
|
||||
icon={<PencilSimpleIcon weight="regular" />}
|
||||
onClick={() => setEdit({ kind: 'edit', record: r })}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('dict.action.close')}
|
||||
variant="danger"
|
||||
icon={<XCircleIcon weight="regular" />}
|
||||
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
|
||||
/>
|
||||
{canMutate && (
|
||||
<>
|
||||
<IconButton
|
||||
label={t('dict.action.edit')}
|
||||
variant="default"
|
||||
icon={<PencilSimpleIcon weight="regular" />}
|
||||
onClick={() => setEdit({ kind: 'edit', record: r })}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('dict.action.close')}
|
||||
variant="danger"
|
||||
icon={<XCircleIcon weight="regular" />}
|
||||
onClick={() => setEdit({ kind: 'close-confirm', record: r })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -18,9 +18,11 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui'
|
||||
import { PlusIcon } from '@phosphor-icons/react'
|
||||
import { useDictionaries, useDictionaryDependents } from '@/api/queries'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries'
|
||||
import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client'
|
||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||
import { useCanMutate } from '@/auth/useCanMutate'
|
||||
import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||
|
||||
// ===== Search params =====
|
||||
@@ -101,6 +103,7 @@ function DictionariesPage() {
|
||||
const search = Route.useSearch()
|
||||
const { data, isLoading, error } = useDictionaries()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const canMutate = useCanMutate()
|
||||
|
||||
const q = (search.q ?? '').trim().toLowerCase()
|
||||
const deferredQuery = useDeferredValue(q)
|
||||
@@ -130,19 +133,40 @@ function DictionariesPage() {
|
||||
|
||||
const setBundle = (bundle: string | undefined) => setSearch({ bundle })
|
||||
|
||||
// Батчевая загрузка refBy для всех словарей — для активации withDeps filter.
|
||||
// useQueries dedup'ит c per-card useDictionaryDependents (тот же queryKey),
|
||||
// так что N=37 запросов выполнятся один раз и cached. Запускается ТОЛЬКО
|
||||
// когда deps filter активен — для anonymous browsing N+1 burst не нужен.
|
||||
const dependentsResults = useQueries({
|
||||
queries: withDepsOnly && data
|
||||
? data.map((d) => ({ ...dictionaryDependentsQuery(d.name) }))
|
||||
: [],
|
||||
})
|
||||
const dependentsMap = useMemo(() => {
|
||||
const m = new Map<string, number>()
|
||||
if (!withDepsOnly || !data) return m
|
||||
data.forEach((d, i) => {
|
||||
const r = dependentsResults[i]
|
||||
// Пока loading или error — считаем 0 (не показываем). После загрузки —
|
||||
// refBy.length. Уникализация не нужна для бинарного hasDeps теста.
|
||||
m.set(d.name, r?.data?.length ?? 0)
|
||||
})
|
||||
return m
|
||||
}, [data, dependentsResults, withDepsOnly])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
return data.filter((d) => {
|
||||
if (scopeFilter.size > 0 && !scopeFilter.has(d.scope)) return false
|
||||
if (bundleFilter && d.bundle !== bundleFilter) return false
|
||||
if (!matchesQuery(d, deferredQuery)) return false
|
||||
// withDepsOnly: пока нет FK metadata в DictionaryDefinition, фильтр
|
||||
// эффективно no-op. После backend batch FK endpoint — заработает.
|
||||
// TODO: backend пишет fk + refBy в DictionaryDefinition; раскомментить:
|
||||
// if (withDepsOnly && d.fk.length === 0 && d.refBy.length === 0) return false
|
||||
// «Со связями» — справочник используется в других справочниках (refBy > 0).
|
||||
// Outgoing FK потребовал бы загрузки полной schema per dict — not worth
|
||||
// the bandwidth для catalog filter. refBy достаточно для semantic.
|
||||
if (withDepsOnly && (dependentsMap.get(d.name) ?? 0) === 0) return false
|
||||
return true
|
||||
})
|
||||
}, [data, deferredQuery, scopeFilter, bundleFilter])
|
||||
}, [data, deferredQuery, scopeFilter, bundleFilter, withDepsOnly, dependentsMap])
|
||||
|
||||
const bundleCounts = useMemo(() => {
|
||||
const out = new Map<string, number>()
|
||||
@@ -160,7 +184,9 @@ function DictionariesPage() {
|
||||
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
|
||||
const resetFilters = () => navigate({ search: {} })
|
||||
|
||||
const createButton = (
|
||||
// Guest mode (anonymous) — кнопка «Создать справочник» скрыта. Backend всё
|
||||
// равно вернёт 401 на POST, но UI hide избегает confusing UX.
|
||||
const createButton = canMutate ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
@@ -170,7 +196,7 @@ function DictionariesPage() {
|
||||
>
|
||||
{t('schema.action.create')}
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
|
||||
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
|
||||
if (error) {
|
||||
|
||||
Reference in New Issue
Block a user