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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import axios from 'axios'
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
LoadingBlock,
|
||||
@@ -21,10 +22,10 @@ import {
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from '@nstart/ui'
|
||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react'
|
||||
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 { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||
import type { CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||
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'
|
||||
@@ -97,6 +98,7 @@ function DictionaryDetail() {
|
||||
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('')
|
||||
@@ -104,6 +106,15 @@ function DictionaryDetail() {
|
||||
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>>(
|
||||
@@ -178,6 +189,85 @@ function DictionaryDetail() {
|
||||
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
|
||||
@@ -383,23 +473,50 @@ function DictionaryDetail() {
|
||||
{filteredRecords.length === 0 ? (
|
||||
<EmptyState title={t('dict.filter.noMatches')} />
|
||||
) : (
|
||||
<Panel>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<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>
|
||||
<>
|
||||
{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}>
|
||||
<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>
|
||||
@@ -466,9 +583,10 @@ function DictionaryDetail() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -602,6 +720,185 @@ function DictionaryDetail() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user