Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
T
Zimin A.N. 5941207955 feat(records): bulk close — multi-select toolbar + per-record транзакция
Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].

Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).

Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.

Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
2026-05-07 11:19:57 +03:00

948 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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
}
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))
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<EditState>({ kind: 'closed' })
const [closeReason, setCloseReason] = useState('')
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
// 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
// Каноничный список видимых ключей — для 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<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))
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 (
<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={
<div className="flex items-center gap-2">
<Button
variant="secondary"
leftIcon={<MapTrifoldIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setAoiOpen(true)}
>
{t('aoi.button')}
</Button>
<Button
variant="secondary"
leftIcon={<GearIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setSchemaEditOpen(true)}
>
{t('schema.action.editSchema')}
</Button>
<Button
variant="primary"
leftIcon={<PlusIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setEdit({ kind: 'create' })}
>
{t('dict.action.create')}
</Button>
</div>
}
/>
{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>
)}
{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}
onClear={clearSelection}
/>
)}
<Panel>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>
<Checkbox
aria-label={t('dict.bulk.selectAll')}
checked={allVisibleSelected}
// Native checkbox indeterminate через ref — для partial select
ref={(el) => {
if (el) el.indeterminate = someVisibleSelected
}}
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>
{filteredRecords.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>
<span className="font-mono text-2xs">{r.businessKey}</span>
</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>
</>
)}
</>
)}
<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 = now (а не historical
// validFrom существующей записи): иначе юзер выберет момент
// ДО любой версии и backend не найдёт что закрывать.
// validTo пустой = бессрочно (или до следующей правки).
validFrom: nowIsoLocal(),
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}
/>
<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
onClear: () => void
}
function BulkSelectionToolbar({
count,
totalVisible,
onBulkClose,
onClear,
}: 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>
<Button
type="button"
variant="danger"
leftIcon={<TrashIcon weight="bold" size={14} />}
onClick={onBulkClose}
>
{t('dict.bulk.closeAction')}
</Button>
</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'
}