import { useEffect, useMemo, 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 } from '@phosphor-icons/react' import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries' import { useBulkCloseRecords, useCreateRecord, useUpdateRecord, useCloseRecord } 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 { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { nowIsoLocal } 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 } const SCOPE_VALUES: ReadonlySet = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) const validateSearch = (raw: Record): 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 } 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(() => bboxToAoi(urlSearch.bbox)) const filter: RecordsFilter | undefined = aoi ? aoi.kind === 'bbox' ? { bbox: aoi.bboxCsv } : { polygon: aoi.geojson } : undefined const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter) const createMut = useCreateRecord(name) const updateMut = useUpdateRecord(name) const closeMut = useCloseRecord(name) const bulkCloseMut = useBulkCloseRecords(name) const [edit, setEdit] = useState({ kind: 'closed' }) const [closeReason, setCloseReason] = useState('') const [schemaEditOpen, setSchemaEditOpen] = useState(false) const [historyKey, setHistoryKey] = useState(undefined) const [aoiOpen, setAoiOpen] = useState(false) // Multi-select state. Set — стабильное id'шник. При изменении // records data (mutation success / refetch / filter change) — selection // не очищаем автоматически, но выкидываем те ключи которых больше нет // в видимом фильтре (избегаем "невидимых" выбранных строк). const [selection, setSelection] = useState>(new Set()) const [bulkOpen, setBulkOpen] = useState(false) const [bulkReason, setBulkReason] = useState('') const [bulkResult, setBulkResult] = useState(null) // Search & scope filter — URL-derived. Empty/missing = все scope активны. const search = urlSearch.q ?? '' const scopeFilter = useMemo>( () => 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 // Каноничный список видимых ключей — для select-all и для очистки selection // от ключей которые больше не видны (filter изменился, запись закрылась). const visibleKeys = useMemo( () => filteredRecords.map((r) => r.businessKey), [filteredRecords], ) // Когда видимый набор сократился — выкидываем из 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() 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)) 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 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) => { if (edit.kind === 'create') { createMut.mutate(req, { onSuccess: () => setEdit({ kind: 'closed' }) }) return } if (edit.kind === 'edit') { updateMut.mutate( { businessKey: edit.record.businessKey, payload: req }, { onSuccess: () => setEdit({ kind: 'closed' }) }, ) } } const handleClose = () => { if (edit.kind !== 'close-confirm') return closeMut.mutate( { businessKey: edit.record.businessKey, reason: closeReason || undefined }, { onSuccess: () => { setEdit({ kind: 'closed' }) setCloseReason('') }, }, ) } 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 (
{scope && ( } title={detailQuery.data?.displayName ?? name} description={ detailQuery.data?.description ? `${detailQuery.data.description} · ${totalRecords} ${t('dict.list.records')}` : `${totalRecords} ${t('dict.list.records')}` } actions={
} /> {aoi && (
{aoi.kind === 'bbox' ? t('aoi.activeBbox', { value: aoi.bboxCsv }) : t('aoi.activePolygon', { points: aoi.geojson.coordinates[0]?.length ?? 0, })}
)} {recordsResult.isLoading && } {recordsResult.error && ( {String(recordsResult.error)} )} {recordsResult.data && recordsResult.data.length === 0 && ( )} {recordsResult.data && recordsResult.data.length > 0 && ( <>
setSearch(e.target.value)} placeholder={t('dict.filter.searchPlaceholder')} aria-label={t('dict.filter.searchPlaceholder')} />
{SCOPE_ORDER.map((s) => { const active = scopeFilter.has(s) return ( ) })}
{filtersActive && (
{t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
)}
{filteredRecords.length === 0 ? ( ) : ( <> {selection.size > 0 && ( )} { if (el) el.indeterminate = someVisibleSelected }} onChange={toggleSelectAll} /> {t('dict.col.businessKey')} name / code {extraColumns.map((c) => ( {c.label} ))} {t('dict.col.scope')} {t('dict.col.validFrom')} {t('dict.col.actions')} {filteredRecords.map((r) => ( toggleSelect(r.businessKey)} /> {r.businessKey} {recordDisplayName( r.data, detailQuery.data?.defaultLocale ?? 'ru-RU', )} {extraColumns.map((c) => { const v = r.data?.[c.key] if (v == null || v === '') { return ( ) } const text = String(v) return ( {text} ) })} {r.dataScope} {new Date(r.validFrom).toLocaleDateString()}
} onClick={() => setHistoryKey(r.businessKey)} /> } onClick={() => setEdit({ kind: 'edit', record: r })} /> } onClick={() => setEdit({ kind: 'close-confirm', record: r })} />
))}
)} )} 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' && ( setEdit({ kind: 'closed' })} /> )} {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.isLoading && ( )} {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && ( {String(rawRecordQuery.error)} )} {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.data && ( setEdit({ kind: 'closed' })} /> )} { setEdit({ kind: 'closed' }) setCloseReason('') }} title={t('dict.confirmClose.title')} maxWidth="max-w-md" > {edit.kind === 'close-confirm' && (

{t('dict.confirmClose.body', { key: edit.record.businessKey })}

setCloseReason(e.target.value)} /> {serverErrorMessage(closeMut.error) && ( {serverErrorMessage(closeMut.error)} )}
)}
{detailQuery.data && ( setSchemaEditOpen(false)} onSuccess={() => setSchemaEditOpen(false)} /> )} setHistoryKey(undefined)} dictionaryName={name} businessKey={historyKey} /> setAoiOpen(false)} onApply={handleAoiApply} initial={aoi?.geojson ?? null} /> { setBulkOpen(false) setBulkResult(null) setBulkReason('') }} title={t('dict.bulk.confirmTitle')} maxWidth="max-w-lg" > { setBulkOpen(false) setBulkResult(null) setBulkReason('') }} isPending={bulkCloseMut.isPending} serverError={serverErrorMessage(bulkCloseMut.error)} result={bulkResult} />
) } type BulkSelectionToolbarProps = { count: number totalVisible: number onBulkClose: () => void onClear: () => void } function BulkSelectionToolbar({ count, totalVisible, onBulkClose, onClear, }: BulkSelectionToolbarProps) { const { t } = useTranslation() return (
{t('dict.bulk.selectedCount', { count, total: totalVisible })}
) } 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 (
{result.closed.length > 0 && (
{t('dict.bulk.resultClosed', { count: result.closed.length })}
)} {result.skipped.length > 0 && (
{t('dict.bulk.resultSkipped', { count: result.skipped.length })}
)} {result.errors.length > 0 && (
{t('dict.bulk.resultErrors', { count: result.errors.length })}
)}
{(result.skipped.length > 0 || result.errors.length > 0) && (
    {result.skipped.map((s) => (
  • · {s.businessKey} — {s.reason}
  • ))} {result.errors.map((e) => (
  • ! {e.businessKey} — {e.reason}
  • ))}
)}
) } return (

{t('dict.bulk.confirmBody', { count })}

onReasonChange(e.target.value)} /> {serverError && {serverError}}
) } /** * 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' }