1313 lines
49 KiB
TypeScript
1313 lines
49 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||
import { useTranslation } from 'react-i18next'
|
||
import axios from 'axios'
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Button,
|
||
Checkbox,
|
||
EmptyState,
|
||
IconButton,
|
||
LoadingBlock,
|
||
Modal,
|
||
PageHeader,
|
||
Panel,
|
||
SearchInput,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeaderCell,
|
||
TableRow,
|
||
TextInput,
|
||
} from '@nstart/ui'
|
||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
||
import {
|
||
useDictionaryDetail,
|
||
useDictPendingDrafts,
|
||
useRecordRaw,
|
||
useRecords,
|
||
type RecordsFilter,
|
||
} from '@/api/queries'
|
||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
|
||
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
||
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 { recordDisplayName } from '@/lib/locales'
|
||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||
|
||
/**
|
||
* URL search params для filter state — share-friendly, persist между F5.
|
||
* - q: search query (free text)
|
||
* - scopes: csv subset of PUBLIC/INTERNAL/RESTRICTED. Empty/missing = все.
|
||
* - bbox: AOI как CSV "west,south,east,north". Polygon AOI в URL не
|
||
* сериализуем — слишком длинно (50+ точек), оставляем в local state.
|
||
* Если bbox задан — initial AOI установится в bbox-режим при mount'е.
|
||
*/
|
||
type DictSearch = {
|
||
q?: string
|
||
scopes?: string
|
||
bbox?: string
|
||
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
|
||
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
|
||
at?: string
|
||
/** View mode: 'records' (default — table + filters) | 'hub' (relations graph
|
||
* с current dict в центре + neighbors на боках). См. DictionaryHubView. */
|
||
view?: 'records' | 'hub'
|
||
}
|
||
|
||
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
|
||
|
||
const validateSearch = (raw: Record<string, unknown>): DictSearch => {
|
||
const out: DictSearch = {}
|
||
if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q
|
||
if (typeof raw.scopes === 'string') {
|
||
const valid = raw.scopes
|
||
.split(',')
|
||
.map((s) => s.trim().toUpperCase())
|
||
.filter((s): s is DataScope => SCOPE_VALUES.has(s as DataScope))
|
||
if (valid.length > 0) out.scopes = valid.join(',')
|
||
}
|
||
if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) {
|
||
out.bbox = raw.bbox
|
||
}
|
||
// ISO datetime check: 2026-05-08T12:00:00Z или 2026-05-08T12:00:00+03:00.
|
||
if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
|
||
out.at = raw.at
|
||
}
|
||
if (raw.view === 'hub' || raw.view === 'records') out.view = raw.view
|
||
return out
|
||
}
|
||
|
||
export const Route = createFileRoute('/dictionaries/$name')({
|
||
component: DictionaryDetail,
|
||
validateSearch,
|
||
})
|
||
|
||
type EditState =
|
||
| { kind: 'closed' }
|
||
| { kind: 'create' }
|
||
| { kind: 'edit'; record: FlattenedRecord }
|
||
| { kind: 'close-confirm'; record: FlattenedRecord }
|
||
|
||
function DictionaryDetail() {
|
||
const { name } = Route.useParams()
|
||
const urlSearch = Route.useSearch()
|
||
const navigate = useNavigate({ from: Route.fullPath })
|
||
const { t } = useTranslation()
|
||
const detailQuery = useDictionaryDetail(name)
|
||
|
||
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
|
||
// только в local state (не сериализуем в URL — слишком длинно).
|
||
// setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL).
|
||
const [aoi, setAoi] = useState<AoiResult | null>(() => bboxToAoi(urlSearch.bbox))
|
||
|
||
// Time-travel state — keep ISO string in URL так чтобы user мог share link
|
||
// "посмотри dict на 2025-12-31T00:00:00Z". Empty/undefined = now().
|
||
const timeTravelAt: string | undefined = urlSearch.at
|
||
const setTimeTravelAt = (next: string | undefined) => {
|
||
void navigate({
|
||
search: (prev) => ({ ...prev, at: next || undefined }),
|
||
replace: true,
|
||
})
|
||
}
|
||
|
||
const filter: RecordsFilter | undefined =
|
||
aoi || timeTravelAt
|
||
? {
|
||
...(aoi?.kind === 'bbox' ? { bbox: aoi.bboxCsv } : {}),
|
||
...(aoi?.kind === 'polygon' ? { polygon: aoi.geojson } : {}),
|
||
...(timeTravelAt ? { at: timeTravelAt } : {}),
|
||
}
|
||
: undefined
|
||
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
||
/**
|
||
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
|
||
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
|
||
* dict не approval-required (нет смысла грузить).
|
||
*/
|
||
const pendingDraftsQuery = useDictPendingDrafts(
|
||
detailQuery.data?.approvalRequired ? name : undefined,
|
||
)
|
||
const pendingByKey = useMemo(() => {
|
||
const set = new Set<string>()
|
||
for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey)
|
||
return set
|
||
}, [pendingDraftsQuery.data])
|
||
const createMut = useCreateRecord(name)
|
||
const updateMut = useUpdateRecord(name)
|
||
// Per-form idempotency key. Stable между ререндерами для одной сессии edit
|
||
// modal'а: двойной submit (Enter + click) не создаст 2 record'а — backend
|
||
// deduplicate'ит по тому же ключу. Reset на onSuccess.
|
||
const [recordIdempotencyKey, resetRecordIdempotencyKey] = useFormIdempotencyKey()
|
||
const closeMut = useCloseRecord(name)
|
||
const bulkCloseMut = useBulkCloseRecords(name)
|
||
const bulkExportMut = useBulkExportRecords(name)
|
||
|
||
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||
const [closeReason, setCloseReason] = useState('')
|
||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||
const [aoiOpen, setAoiOpen] = useState(false)
|
||
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
||
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
||
* на close, или явным action из record menu. */
|
||
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
||
|
||
// Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
|
||
// records data (mutation success / refetch / filter change) — selection
|
||
// не очищаем автоматически, но выкидываем те ключи которых больше нет
|
||
// в видимом фильтре (избегаем "невидимых" выбранных строк).
|
||
const [selection, setSelection] = useState<Set<string>>(new Set())
|
||
const [bulkOpen, setBulkOpen] = useState(false)
|
||
const [bulkReason, setBulkReason] = useState('')
|
||
const [bulkResult, setBulkResult] = useState<BulkCloseResponse | null>(null)
|
||
|
||
// Search & scope filter — URL-derived. Empty/missing = все scope активны.
|
||
const search = urlSearch.q ?? ''
|
||
const scopeFilter = useMemo<Set<DataScope>>(
|
||
() => new Set(urlSearch.scopes ? (urlSearch.scopes.split(',') as DataScope[]) : []),
|
||
[urlSearch.scopes],
|
||
)
|
||
|
||
const setSearch = (next: string) => {
|
||
void navigate({
|
||
search: (prev) => ({ ...prev, q: next.length > 0 ? next : undefined }),
|
||
replace: true,
|
||
})
|
||
}
|
||
|
||
const toggleScope = (scope: DataScope) => {
|
||
const next = new Set(scopeFilter)
|
||
if (next.has(scope)) next.delete(scope)
|
||
else next.add(scope)
|
||
void navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
scopes: next.size > 0 ? Array.from(next).join(',') : undefined,
|
||
}),
|
||
replace: true,
|
||
})
|
||
}
|
||
|
||
const handleAoiApply = (result: AoiResult) => {
|
||
setAoi(result)
|
||
// bbox в URL для share; polygon оставляем в memory.
|
||
void navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
bbox: result.kind === 'bbox' ? result.bboxCsv : undefined,
|
||
}),
|
||
replace: true,
|
||
})
|
||
}
|
||
|
||
const handleAoiClear = () => {
|
||
setAoi(null)
|
||
void navigate({
|
||
search: (prev) => ({ ...prev, bbox: undefined }),
|
||
replace: true,
|
||
})
|
||
}
|
||
|
||
const handleClearFilters = () => {
|
||
void navigate({
|
||
search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
|
||
replace: true,
|
||
})
|
||
}
|
||
|
||
const filteredRecords = useMemo(() => {
|
||
const raw = recordsResult.data ?? []
|
||
if (raw.length === 0) return raw
|
||
const q = search.trim().toLowerCase()
|
||
const scopes = scopeFilter
|
||
return raw.filter((r) => {
|
||
if (scopes.size > 0 && !scopes.has(r.dataScope)) return false
|
||
if (q.length === 0) return true
|
||
// Search по businessKey + JSON.stringify(data) — простой,
|
||
// покрывает локализованные поля, code, описания. Stringify
|
||
// O(n) в memory, для 100 записей мгновенно.
|
||
if (r.businessKey.toLowerCase().includes(q)) return true
|
||
const haystack = JSON.stringify(r.data).toLowerCase()
|
||
return haystack.includes(q)
|
||
})
|
||
}, [recordsResult.data, search, scopeFilter])
|
||
|
||
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
||
const filteredCount = filteredRecords.length
|
||
|
||
// Client-side pagination — типичный словарь до 100 записей, server-side
|
||
// пейджинг overkill. Page 0-indexed. PAGE_SIZE = 50 — достаточно чтобы
|
||
// увидеть все основные dictionaries без листания.
|
||
const PAGE_SIZE = 50
|
||
const [page, setPage] = useState(0)
|
||
const totalPages = Math.max(1, Math.ceil(filteredCount / PAGE_SIZE))
|
||
|
||
// При изменении filter — на page 0 (иначе page может оказаться вне диапазона)
|
||
useEffect(() => {
|
||
setPage(0)
|
||
}, [search, scopeFilter, aoi])
|
||
|
||
const visibleRecords = useMemo(
|
||
() => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
|
||
[filteredRecords, page],
|
||
)
|
||
|
||
// Каноничный список видимых ключей — для select-all и для очистки selection
|
||
// от ключей которые больше не видны (filter изменился, запись закрылась).
|
||
// С client-side pagination "видимые" = только текущая страница, чтобы
|
||
// select-all чекбокс трогал ровно то что юзер видит на экране.
|
||
const visibleKeys = useMemo(
|
||
() => visibleRecords.map((r) => r.businessKey),
|
||
[visibleRecords],
|
||
)
|
||
|
||
// Когда видимый набор сократился — выкидываем из selection невидимые ключи.
|
||
// Иначе bulk close может пытаться закрыть запись которую юзер уже не видит,
|
||
// что нарушает "WYSIWYG" принцип multi-select toolbar'а.
|
||
useEffect(() => {
|
||
setSelection((prev) => {
|
||
if (prev.size === 0) return prev
|
||
const visibleSet = new Set(visibleKeys)
|
||
const next = new Set<string>()
|
||
for (const k of prev) if (visibleSet.has(k)) next.add(k)
|
||
return next.size === prev.size ? prev : next
|
||
})
|
||
}, [visibleKeys])
|
||
|
||
const allVisibleSelected =
|
||
visibleKeys.length > 0 && visibleKeys.every((k) => selection.has(k))
|
||
const someVisibleSelected =
|
||
!allVisibleSelected && visibleKeys.some((k) => selection.has(k))
|
||
|
||
// Native checkbox indeterminate state — sync через useEffect, не callback ref.
|
||
// Callback ref срабатывает только на mount/unmount → state становится stale
|
||
// когда user toggle'ает selection per-row. useEffect re-runs на изменение
|
||
// someVisibleSelected. См. design handoff review #17 + Checkbox в @nstart/ui:
|
||
// ref forwarded к нативному <input>, но `indeterminate` это JS DOM-only prop
|
||
// (не HTML attribute), нужен manual sync.
|
||
const selectAllRef = useRef<HTMLInputElement>(null)
|
||
useEffect(() => {
|
||
if (selectAllRef.current) {
|
||
selectAllRef.current.indeterminate = someVisibleSelected
|
||
}
|
||
}, [someVisibleSelected])
|
||
|
||
const toggleSelect = (key: string) => {
|
||
setSelection((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(key)) next.delete(key)
|
||
else next.add(key)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const toggleSelectAll = () => {
|
||
setSelection((prev) => {
|
||
if (visibleKeys.every((k) => prev.has(k))) {
|
||
// Все видимые выбраны → снимаем именно их (не трогаем невидимые,
|
||
// если такие были; на практике их не должно быть благодаря useEffect)
|
||
const next = new Set(prev)
|
||
for (const k of visibleKeys) next.delete(k)
|
||
return next
|
||
}
|
||
const next = new Set(prev)
|
||
for (const k of visibleKeys) next.add(k)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const clearSelection = () => setSelection(new Set())
|
||
|
||
const openBulkClose = () => {
|
||
setBulkResult(null)
|
||
setBulkReason('')
|
||
setBulkOpen(true)
|
||
}
|
||
|
||
const handleBulkExport = () => {
|
||
if (selection.size === 0) return
|
||
bulkExportMut.mutate(Array.from(selection))
|
||
}
|
||
|
||
const handleBulkClose = () => {
|
||
if (selection.size === 0) return
|
||
bulkCloseMut.mutate(
|
||
{
|
||
businessKeys: Array.from(selection),
|
||
reason: bulkReason || undefined,
|
||
},
|
||
{
|
||
onSuccess: (res) => {
|
||
setBulkResult(res)
|
||
// Снимаем selection с успешно закрытых; оставляем skipped/errors
|
||
// чтобы юзер видел что ещё не сделано.
|
||
setSelection((prev) => {
|
||
const next = new Set(prev)
|
||
for (const k of res.closed) next.delete(k)
|
||
return next
|
||
})
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
// Schema-driven extra columns: enum + reference поля — самые info-dense
|
||
// не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
|
||
// строку (с businessKey + name + scope + extras + дата + actions = 6
|
||
// колонок, comfortably fits на 1280px). Skip name/code/businessKey
|
||
// (уже показаны), всё нескалярное (object/array/localized).
|
||
const extraColumns = useMemo<{ key: string; label: string; isFk: boolean }[]>(() => {
|
||
const props = detailQuery.data?.schemaJson?.properties ?? {}
|
||
const interesting: { key: string; label: string; isFk: boolean }[] = []
|
||
for (const [key, prop] of Object.entries(props)) {
|
||
if (interesting.length >= 2) break
|
||
if (key === 'name' || key === 'code' || key === 'businessKey') continue
|
||
const isLocalized = Boolean(prop['x-localized'])
|
||
if (isLocalized) continue
|
||
const isEnum = Array.isArray(prop.enum) && prop.enum.length > 0
|
||
const isFk = typeof prop['x-references'] === 'string' && Boolean(prop['x-references'])
|
||
if (!isEnum && !isFk) continue
|
||
interesting.push({ key, label: humanize(key), isFk })
|
||
}
|
||
return interesting
|
||
}, [detailQuery.data?.schemaJson])
|
||
|
||
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
|
||
const rawRecordQuery = useRecordRaw(name, editingKey)
|
||
|
||
const handleSubmit = (req: CreateRecordRequest) => {
|
||
// Fill validFrom at submit (review #5): defaultValues в RHF берётся
|
||
// ТОЛЬКО при mount. Если user открыл modal в 12:00 и submit в 12:05 —
|
||
// `validFrom` в req это значение из 12:00. Backend check `validFrom >
|
||
// existingFrom` может fail если existing запись свежее. Submit-time
|
||
// refresh гарантирует что validFrom не устарел между mount и submit.
|
||
const submitReq: CreateRecordRequest = {
|
||
...req,
|
||
validFrom: req.validFrom && req.validFrom.length > 0 ? req.validFrom : nowIsoLocal(),
|
||
}
|
||
if (edit.kind === 'create') {
|
||
createMut.mutate(
|
||
{ payload: submitReq, idempotencyKey: recordIdempotencyKey },
|
||
{
|
||
onSuccess: () => {
|
||
setEdit({ kind: 'closed' })
|
||
resetRecordIdempotencyKey()
|
||
},
|
||
},
|
||
)
|
||
return
|
||
}
|
||
if (edit.kind === 'edit') {
|
||
updateMut.mutate(
|
||
{
|
||
businessKey: edit.record.businessKey,
|
||
payload: submitReq,
|
||
idempotencyKey: recordIdempotencyKey,
|
||
},
|
||
{
|
||
onSuccess: () => {
|
||
setEdit({ kind: 'closed' })
|
||
resetRecordIdempotencyKey()
|
||
},
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
const handleClose = () => {
|
||
if (edit.kind !== 'close-confirm') return
|
||
const targetKey = edit.record.businessKey
|
||
closeMut.mutate(
|
||
{ businessKey: targetKey, reason: closeReason || undefined },
|
||
{
|
||
onSuccess: () => {
|
||
setEdit({ kind: 'closed' })
|
||
setCloseReason('')
|
||
},
|
||
onError: (err: unknown) => {
|
||
// Phase 3 dict-relationships-v2: 409 с cascade-related code
|
||
// открывает CascadeConfirmDialog. Иначе error inline в close-confirm.
|
||
const status =
|
||
(err as { response?: { status?: number } })?.response?.status
|
||
const code = (err as { response?: { data?: { code?: string } } })
|
||
?.response?.data?.code
|
||
if (
|
||
status === 409 &&
|
||
(code === 'x_references_blocked_by_dependents' ||
|
||
code === 'x_references_cascade_required')
|
||
) {
|
||
// Reset closeMut.error до открытия cascade dialog'а — иначе stale
|
||
// 409 mesg может проскочить в открытом cascade UI пока user
|
||
// ещё не сделал retry (review #18).
|
||
closeMut.reset()
|
||
setEdit({ kind: 'closed' })
|
||
setCloseReason('')
|
||
setCascadeKey(targetKey)
|
||
}
|
||
},
|
||
},
|
||
)
|
||
}
|
||
|
||
const activeMutation =
|
||
edit.kind === 'create' ? createMut : edit.kind === 'edit' ? updateMut : null
|
||
const serverError = serverErrorMessage(activeMutation?.error)
|
||
|
||
const totalRecords = recordsResult.data?.length ?? 0
|
||
const scope = detailQuery.data?.scope
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{scope && (
|
||
<div
|
||
className={`-mt-6 -mx-4 sm:-mx-6 lg:-mx-8 mb-2 h-1 border-t-4 ${SCOPE_BORDER_TOP[scope]}`}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<PageHeader
|
||
breadcrumb={
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<Link to="/dictionaries" className="text-carbon/70 hover:text-ultramarain">
|
||
← {t('nav.dictionaries')}
|
||
</Link>
|
||
{scope && (
|
||
<span className="flex items-center gap-1.5 text-carbon/70">
|
||
<span className="text-carbon/40">·</span>
|
||
<span
|
||
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
|
||
aria-hidden="true"
|
||
/>
|
||
<span className="font-medium">{t(`dict.list.section.${scope}`)}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
}
|
||
title={detailQuery.data?.displayName ?? name}
|
||
description={
|
||
detailQuery.data?.description
|
||
? `${detailQuery.data.description} · ${totalRecords} ${t('dict.list.records')}`
|
||
: `${totalRecords} ${t('dict.list.records')}`
|
||
}
|
||
actions={
|
||
// flex-wrap: при узком viewport кнопки переносятся на новую row
|
||
// вместо вмятия в 2 строки внутри одной кнопки. whitespace-nowrap на
|
||
// каждой Button — label всегда single line. Combination: buttons
|
||
// expand to fit text, и весь toolbar wraps below header при узком
|
||
// экране, а не давит лейблы.
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
leftIcon={<MapTrifoldIcon weight="bold" size={16} />}
|
||
disabled={!detailQuery.data}
|
||
onClick={() => setAoiOpen(true)}
|
||
className="whitespace-nowrap"
|
||
>
|
||
{t('aoi.button')}
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
leftIcon={<ClockCounterClockwiseIcon weight="bold" size={16} />}
|
||
disabled={!detailQuery.data}
|
||
onClick={() => setTimeTravelOpen((v) => !v)}
|
||
aria-pressed={Boolean(timeTravelAt)}
|
||
className="whitespace-nowrap"
|
||
>
|
||
{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>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* View toggle: Записи (default table-based view) | Связи (Hub view с
|
||
neighbors на боках). Per design handoff B. */}
|
||
{detailQuery.data && (
|
||
<div role="group" aria-label="View mode" className="flex border border-regolith rounded-sm overflow-hidden w-fit">
|
||
{(['records', 'hub'] as const).map((mode) => {
|
||
const active = (urlSearch.view ?? 'records') === mode
|
||
return (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() =>
|
||
void navigate({
|
||
search: (prev) => ({
|
||
...prev,
|
||
view: mode === 'records' ? undefined : mode,
|
||
}),
|
||
replace: true,
|
||
})
|
||
}
|
||
aria-pressed={active}
|
||
className={`px-3 py-1 text-2xs uppercase tracking-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
|
||
active
|
||
? 'bg-orbit/40 text-ultramarain'
|
||
: 'hover:bg-orbit/20 text-carbon'
|
||
}`}
|
||
>
|
||
{t(`dict.view.${mode}`, {
|
||
defaultValue: mode === 'records' ? 'Записи' : 'Связи',
|
||
})}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{urlSearch.view === 'hub' && detailQuery.data ? (
|
||
<DictionaryHubView detail={detailQuery.data} />
|
||
) : (
|
||
/* Phase 1 dict-relationships-v2: schema-level reverse FK card.
|
||
Hide-on-empty — большинство справочников без incoming refs не
|
||
увидят этот блок. */
|
||
<DictionaryDependentsPanel dictionaryName={name} />
|
||
)}
|
||
|
||
{aoi && (
|
||
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">
|
||
<span className="font-mono text-xs text-carbon/80">
|
||
{aoi.kind === 'bbox'
|
||
? t('aoi.activeBbox', { value: aoi.bboxCsv })
|
||
: t('aoi.activePolygon', {
|
||
points: aoi.geojson.coordinates[0]?.length ?? 0,
|
||
})}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="text-xs text-ultramarain hover:underline"
|
||
onClick={handleAoiClear}
|
||
>
|
||
{t('aoi.clear')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Time-travel picker — раскрывается по клику на toolbar button.
|
||
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>
|
||
)}
|
||
|
||
{/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
|
||
{timeTravelAt && !timeTravelOpen && (
|
||
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 text-sm">
|
||
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-orbit shrink-0" />
|
||
<span className="font-mono text-xs text-carbon/80">
|
||
{t('timeTravel.viewing', {
|
||
date: new Date(timeTravelAt).toLocaleString(),
|
||
})}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="text-xs text-ultramarain hover:underline ml-auto"
|
||
onClick={() => setTimeTravelAt(undefined)}
|
||
>
|
||
{t('timeTravel.clear')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{recordsResult.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||
|
||
{recordsResult.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(recordsResult.error)}
|
||
</Alert>
|
||
)}
|
||
|
||
{recordsResult.data && recordsResult.data.length === 0 && (
|
||
<EmptyState title={t('dict.empty')} />
|
||
)}
|
||
|
||
{recordsResult.data && recordsResult.data.length > 0 && (
|
||
<>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="flex-1 min-w-[280px] max-w-md">
|
||
<SearchInput
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder={t('dict.filter.searchPlaceholder')}
|
||
aria-label={t('dict.filter.searchPlaceholder')}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1.5" role="group" aria-label={t('dict.filter.scopeLabel')}>
|
||
{SCOPE_ORDER.map((s) => {
|
||
const active = scopeFilter.has(s)
|
||
return (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
onClick={() => toggleScope(s)}
|
||
aria-pressed={active}
|
||
className={[
|
||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-2xs uppercase tracking-label font-secondary transition-colors',
|
||
active
|
||
? 'border-ultramarain bg-ultramarain/8 text-ultramarain'
|
||
: 'border-regolith bg-white text-carbon/70 hover:border-carbon/40',
|
||
].join(' ')}
|
||
>
|
||
<span
|
||
className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`}
|
||
aria-hidden="true"
|
||
/>
|
||
{t(`dict.list.section.${s}`)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
{filtersActive && (
|
||
<div className="flex items-center gap-2 text-xs text-carbon/70">
|
||
<span>
|
||
{t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleClearFilters}
|
||
className="text-ultramarain hover:underline"
|
||
>
|
||
{t('dict.filter.clear')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{filteredRecords.length === 0 ? (
|
||
<EmptyState title={t('dict.filter.noMatches')} />
|
||
) : (
|
||
<>
|
||
{selection.size > 0 && (
|
||
<BulkSelectionToolbar
|
||
count={selection.size}
|
||
totalVisible={filteredCount}
|
||
onBulkClose={openBulkClose}
|
||
onBulkExport={handleBulkExport}
|
||
onClear={clearSelection}
|
||
isExporting={bulkExportMut.isPending}
|
||
/>
|
||
)}
|
||
<Panel>
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
<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) => (
|
||
<TableHeaderCell key={c.key}>{c.label}</TableHeaderCell>
|
||
))}
|
||
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
|
||
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<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>
|
||
<TableCell>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="font-mono text-2xs">{r.businessKey}</span>
|
||
{pendingByKey.has(r.businessKey) ? (
|
||
<span title={t('dict.pendingReview.tooltip')}>
|
||
<Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
{recordDisplayName(
|
||
r.data,
|
||
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
||
)}
|
||
</TableCell>
|
||
{extraColumns.map((c) => {
|
||
const v = r.data?.[c.key]
|
||
if (v == null || v === '') {
|
||
return (
|
||
<TableCell key={c.key}>
|
||
<span className="text-2xs text-carbon/40">—</span>
|
||
</TableCell>
|
||
)
|
||
}
|
||
const text = String(v)
|
||
return (
|
||
<TableCell key={c.key}>
|
||
<span
|
||
className={[
|
||
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-2xs',
|
||
c.isFk
|
||
? 'bg-ultramarain/8 text-ultramarain'
|
||
: 'bg-carbon/8 text-carbon/80',
|
||
].join(' ')}
|
||
>
|
||
{text}
|
||
</span>
|
||
</TableCell>
|
||
)
|
||
})}
|
||
<TableCell>
|
||
<Badge variant="info">{r.dataScope}</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="text-2xs text-carbon/60">
|
||
{new Date(r.validFrom).toLocaleDateString()}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell align="right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<IconButton
|
||
label={t('history.title')}
|
||
variant="default"
|
||
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 })}
|
||
/>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Panel>
|
||
{totalPages > 1 && (
|
||
<RecordsPagination
|
||
page={page}
|
||
totalPages={totalPages}
|
||
totalCount={filteredCount}
|
||
pageSize={PAGE_SIZE}
|
||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
||
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<Modal
|
||
isOpen={edit.kind === 'create' || edit.kind === 'edit'}
|
||
onClose={() => setEdit({ kind: 'closed' })}
|
||
title={
|
||
edit.kind === 'create'
|
||
? t('dict.action.create')
|
||
: edit.kind === 'edit'
|
||
? `${t('dict.action.edit')}: ${edit.record.businessKey}`
|
||
: ''
|
||
}
|
||
maxWidth="max-w-4xl"
|
||
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
||
bodyClassName="overflow-y-auto flex-1"
|
||
>
|
||
{detailQuery.data && edit.kind === 'create' && (
|
||
<SchemaDrivenForm
|
||
schema={detailQuery.data.schemaJson}
|
||
supportedLocales={detailQuery.data.supportedLocales}
|
||
defaultLocale={detailQuery.data.defaultLocale}
|
||
mode="create"
|
||
isPending={Boolean(activeMutation?.isPending)}
|
||
serverError={serverError}
|
||
onSubmit={handleSubmit}
|
||
onCancel={() => setEdit({ kind: 'closed' })}
|
||
/>
|
||
)}
|
||
|
||
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.isLoading && (
|
||
<LoadingBlock size="md" label={t('loading')} />
|
||
)}
|
||
|
||
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(rawRecordQuery.error)}
|
||
</Alert>
|
||
)}
|
||
|
||
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.data && (
|
||
<SchemaDrivenForm
|
||
schema={detailQuery.data.schemaJson}
|
||
supportedLocales={detailQuery.data.supportedLocales}
|
||
defaultLocale={detailQuery.data.defaultLocale}
|
||
mode="edit"
|
||
isPending={Boolean(activeMutation?.isPending)}
|
||
serverError={serverError}
|
||
defaultValues={{
|
||
businessKey: rawRecordQuery.data.businessKey,
|
||
data: rawRecordQuery.data.data,
|
||
// Edit в bitemporal model = «создаём новую версию с этого момента,
|
||
// старая закрывается». validFrom оставляем пустым — handleSubmit
|
||
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
|
||
// если user долго держит modal открытым).
|
||
// validTo пустой = бессрочно (или до следующей правки).
|
||
validFrom: '',
|
||
validTo: '',
|
||
}}
|
||
onSubmit={handleSubmit}
|
||
onCancel={() => setEdit({ kind: 'closed' })}
|
||
/>
|
||
)}
|
||
</Modal>
|
||
|
||
<Modal
|
||
isOpen={edit.kind === 'close-confirm'}
|
||
onClose={() => {
|
||
setEdit({ kind: 'closed' })
|
||
setCloseReason('')
|
||
}}
|
||
title={t('dict.confirmClose.title')}
|
||
maxWidth="max-w-md"
|
||
>
|
||
{edit.kind === 'close-confirm' && (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-carbon">
|
||
{t('dict.confirmClose.body', { key: edit.record.businessKey })}
|
||
</p>
|
||
<TextInput
|
||
label={t('dict.confirmClose.reason')}
|
||
placeholder={t('dict.confirmClose.reasonPlaceholder')}
|
||
value={closeReason}
|
||
onChange={(e) => setCloseReason(e.target.value)}
|
||
/>
|
||
{serverErrorMessage(closeMut.error) && (
|
||
<Alert variant="error">{serverErrorMessage(closeMut.error)}</Alert>
|
||
)}
|
||
<div className="flex justify-end gap-2 pt-2 border-t border-regolith">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
disabled={closeMut.isPending}
|
||
onClick={() => setEdit({ kind: 'closed' })}
|
||
>
|
||
{t('form.cancel')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="danger"
|
||
loading={closeMut.isPending}
|
||
onClick={handleClose}
|
||
>
|
||
{t('dict.action.close')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{detailQuery.data && (
|
||
<DictionaryEditorDialog
|
||
open={schemaEditOpen}
|
||
mode={{ kind: 'edit', existing: detailQuery.data }}
|
||
onClose={() => setSchemaEditOpen(false)}
|
||
onSuccess={() => setSchemaEditOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
<RecordHistoryDrawer
|
||
open={Boolean(historyKey)}
|
||
onClose={() => setHistoryKey(undefined)}
|
||
dictionaryName={name}
|
||
businessKey={historyKey}
|
||
/>
|
||
|
||
<CascadeConfirmDialog
|
||
open={Boolean(cascadeKey)}
|
||
onClose={() => setCascadeKey(undefined)}
|
||
onSuccess={() => {
|
||
setCascadeKey(undefined)
|
||
recordsResult.refetch()
|
||
}}
|
||
dictionaryName={name}
|
||
businessKey={cascadeKey}
|
||
/>
|
||
|
||
<AoiPickerDialog
|
||
isOpen={aoiOpen}
|
||
onClose={() => setAoiOpen(false)}
|
||
onApply={handleAoiApply}
|
||
initial={aoi?.geojson ?? null}
|
||
/>
|
||
|
||
<Modal
|
||
isOpen={bulkOpen}
|
||
onClose={() => {
|
||
setBulkOpen(false)
|
||
setBulkResult(null)
|
||
setBulkReason('')
|
||
}}
|
||
title={t('dict.bulk.confirmTitle')}
|
||
maxWidth="max-w-lg"
|
||
>
|
||
<BulkCloseDialog
|
||
count={selection.size}
|
||
reason={bulkReason}
|
||
onReasonChange={setBulkReason}
|
||
onSubmit={handleBulkClose}
|
||
onClose={() => {
|
||
setBulkOpen(false)
|
||
setBulkResult(null)
|
||
setBulkReason('')
|
||
}}
|
||
isPending={bulkCloseMut.isPending}
|
||
serverError={serverErrorMessage(bulkCloseMut.error)}
|
||
result={bulkResult}
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type BulkSelectionToolbarProps = {
|
||
count: number
|
||
totalVisible: number
|
||
onBulkClose: () => void
|
||
onBulkExport: () => void
|
||
onClear: () => void
|
||
isExporting: boolean
|
||
}
|
||
|
||
function BulkSelectionToolbar({
|
||
count,
|
||
totalVisible,
|
||
onBulkClose,
|
||
onBulkExport,
|
||
onClear,
|
||
isExporting,
|
||
}: BulkSelectionToolbarProps) {
|
||
const { t } = useTranslation()
|
||
return (
|
||
<div
|
||
className={[
|
||
'sticky top-0 z-10 -mx-1 flex items-center justify-between gap-3',
|
||
'bg-ultramarain text-white rounded-md shadow-sm px-4 py-2',
|
||
].join(' ')}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm font-medium tabular-nums">
|
||
{t('dict.bulk.selectedCount', { count, total: totalVisible })}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={onClear}
|
||
className="inline-flex items-center gap-1 text-2xs underline-offset-2 hover:underline opacity-80 hover:opacity-100"
|
||
>
|
||
<XIcon size={12} weight="bold" />
|
||
{t('dict.bulk.clearSelection')}
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
leftIcon={<DownloadSimpleIcon weight="bold" size={14} />}
|
||
onClick={onBulkExport}
|
||
loading={isExporting}
|
||
>
|
||
{t('dict.bulk.exportAction')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="danger"
|
||
leftIcon={<TrashIcon weight="bold" size={14} />}
|
||
onClick={onBulkClose}
|
||
>
|
||
{t('dict.bulk.closeAction')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type RecordsPaginationProps = {
|
||
page: number
|
||
totalPages: number
|
||
totalCount: number
|
||
pageSize: number
|
||
onPrev: () => void
|
||
onNext: () => void
|
||
}
|
||
|
||
function RecordsPagination({
|
||
page,
|
||
totalPages,
|
||
totalCount,
|
||
pageSize,
|
||
onPrev,
|
||
onNext,
|
||
}: RecordsPaginationProps) {
|
||
const { t } = useTranslation()
|
||
const from = page * pageSize + 1
|
||
const to = Math.min((page + 1) * pageSize, totalCount)
|
||
return (
|
||
<div className="flex items-center justify-between flex-wrap gap-3 px-2">
|
||
<span className="text-2xs text-carbon/70 tabular-nums">
|
||
{t('dict.page.range', { from, to, total: totalCount })}
|
||
</span>
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
leftIcon={<CaretLeftIcon weight="bold" size={14} />}
|
||
onClick={onPrev}
|
||
disabled={page <= 0}
|
||
>
|
||
{t('dict.page.prev')}
|
||
</Button>
|
||
<span className="tabular-nums text-2xs text-carbon/70">
|
||
{t('dict.page.of', { cur: page + 1, total: totalPages })}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
rightIcon={<CaretRightIcon weight="bold" size={14} />}
|
||
onClick={onNext}
|
||
disabled={page + 1 >= totalPages}
|
||
>
|
||
{t('dict.page.next')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type BulkCloseDialogProps = {
|
||
count: number
|
||
reason: string
|
||
onReasonChange: (s: string) => void
|
||
onSubmit: () => void
|
||
onClose: () => void
|
||
isPending: boolean
|
||
serverError: string | null
|
||
result: BulkCloseResponse | null
|
||
}
|
||
|
||
function BulkCloseDialog({
|
||
count,
|
||
reason,
|
||
onReasonChange,
|
||
onSubmit,
|
||
onClose,
|
||
isPending,
|
||
serverError,
|
||
result,
|
||
}: BulkCloseDialogProps) {
|
||
const { t } = useTranslation()
|
||
|
||
// После успеха — показываем сводку; вход на confirm форму прячем
|
||
if (result) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
{result.closed.length > 0 && (
|
||
<div className="flex items-center gap-2 text-sm text-soyuz-green">
|
||
<CheckCircleIcon size={18} weight="fill" />
|
||
<span>{t('dict.bulk.resultClosed', { count: result.closed.length })}</span>
|
||
</div>
|
||
)}
|
||
{result.skipped.length > 0 && (
|
||
<div className="flex items-center gap-2 text-sm text-saturn">
|
||
<WarningIcon size={18} weight="fill" />
|
||
<span>{t('dict.bulk.resultSkipped', { count: result.skipped.length })}</span>
|
||
</div>
|
||
)}
|
||
{result.errors.length > 0 && (
|
||
<div className="flex items-center gap-2 text-sm text-mars">
|
||
<XCircleIcon size={18} weight="fill" />
|
||
<span>{t('dict.bulk.resultErrors', { count: result.errors.length })}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(result.skipped.length > 0 || result.errors.length > 0) && (
|
||
<div className="border border-regolith rounded p-3 max-h-48 overflow-auto bg-regolith/30">
|
||
<ul className="text-2xs font-mono space-y-1">
|
||
{result.skipped.map((s) => (
|
||
<li key={`s-${s.businessKey}`} className="text-saturn">
|
||
· {s.businessKey} — {s.reason}
|
||
</li>
|
||
))}
|
||
{result.errors.map((e) => (
|
||
<li key={`e-${e.businessKey}`} className="text-mars">
|
||
! {e.businessKey} — {e.reason}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end pt-2 border-t border-regolith">
|
||
<Button type="button" variant="primary" onClick={onClose}>
|
||
{t('dict.bulk.dismiss')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-carbon">{t('dict.bulk.confirmBody', { count })}</p>
|
||
<TextInput
|
||
label={t('dict.confirmClose.reason')}
|
||
placeholder={t('dict.confirmClose.reasonPlaceholder')}
|
||
value={reason}
|
||
onChange={(e) => onReasonChange(e.target.value)}
|
||
/>
|
||
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||
<div className="flex justify-end gap-2 pt-2 border-t border-regolith">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
disabled={isPending}
|
||
onClick={onClose}
|
||
>
|
||
{t('form.cancel')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="danger"
|
||
loading={isPending}
|
||
onClick={onSubmit}
|
||
leftIcon={<TrashIcon weight="bold" size={14} />}
|
||
>
|
||
{t('dict.bulk.confirmAction', { count })}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Bootstrap initial AOI из URL bbox param. Возвращает bbox-AOI с
|
||
* прямоугольным GeoJSON для preview в picker'е. Невалидные значения
|
||
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
|
||
*/
|
||
/** snake_case → 'Snake Case' для column header'ов. */
|
||
const humanize = (key: string): string =>
|
||
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
|
||
|
||
const bboxToAoi = (bboxCsv: string | undefined): AoiResult | null => {
|
||
if (!bboxCsv) return null
|
||
const parts = bboxCsv.split(',').map(Number)
|
||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return null
|
||
const [w, s, e, n] = parts
|
||
return {
|
||
kind: 'bbox',
|
||
bboxCsv,
|
||
geojson: {
|
||
type: 'Polygon',
|
||
coordinates: [
|
||
[
|
||
[w, s],
|
||
[e, s],
|
||
[e, n],
|
||
[w, n],
|
||
[w, s],
|
||
],
|
||
],
|
||
},
|
||
}
|
||
}
|
||
|
||
const serverErrorMessage = (err: unknown): string | null => {
|
||
if (!err) return null
|
||
if (axios.isAxiosError(err)) {
|
||
const data = err.response?.data as { message?: string; error?: string } | undefined
|
||
if (err.response?.status === 422) return data?.message ?? 'Validation failed'
|
||
if (err.response?.status === 409) return data?.message ?? 'Conflict — request already in progress'
|
||
return data?.message ?? data?.error ?? err.message
|
||
}
|
||
return err instanceof Error ? err.message : 'Unknown error'
|
||
}
|