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:
@@ -113,6 +113,18 @@ export type CreateRecordRequest = {
|
|||||||
validTo?: string
|
validTo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BulkCloseRequest = {
|
||||||
|
businessKeys: string[]
|
||||||
|
at?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BulkCloseResponse = {
|
||||||
|
closed: string[]
|
||||||
|
skipped: { businessKey: string; reason: string }[]
|
||||||
|
errors: { businessKey: string; reason: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
|
export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
|
||||||
|
|
||||||
export type AuditEntry = {
|
export type AuditEntry = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
apiClient,
|
apiClient,
|
||||||
|
type BulkCloseRequest,
|
||||||
|
type BulkCloseResponse,
|
||||||
type CreateDictionaryRequest,
|
type CreateDictionaryRequest,
|
||||||
type CreateRecordRequest,
|
type CreateRecordRequest,
|
||||||
type CreateWebhookSubscriptionRequest,
|
type CreateWebhookSubscriptionRequest,
|
||||||
@@ -101,6 +103,26 @@ export const useCloseRecord = (dictionaryName: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk close: закрыть N records одним запросом. Backend per-record транзакция,
|
||||||
|
* partial success возможен (already_closed / not_found попадает в skipped).
|
||||||
|
*/
|
||||||
|
export const useBulkCloseRecords = (dictionaryName: string) => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (req: BulkCloseRequest): Promise<BulkCloseResponse> => {
|
||||||
|
const { data } = await apiClient.post<BulkCloseResponse>(
|
||||||
|
`/dictionaries/${dictionaryName}/records/bulk-close`,
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// === Webhooks (v2) ===
|
// === Webhooks (v2) ===
|
||||||
|
|
||||||
export const useCreateWebhook = () => {
|
export const useCreateWebhook = () => {
|
||||||
|
|||||||
@@ -145,6 +145,22 @@ i18n
|
|||||||
'Запись «{{key}}» будет помечена как закрытая (valid_to = now). Действие обратимо через создание новой версии.',
|
'Запись «{{key}}» будет помечена как закрытая (valid_to = now). Действие обратимо через создание новой версии.',
|
||||||
'dict.confirmClose.reason': 'Причина (опционально)',
|
'dict.confirmClose.reason': 'Причина (опционально)',
|
||||||
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
|
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
|
||||||
|
'dict.bulk.selectAll': 'Выбрать все на странице',
|
||||||
|
'dict.bulk.selectRow': 'Выбрать запись',
|
||||||
|
'dict.bulk.selectedCount_one': 'Выбрано {{count}} из {{total}}',
|
||||||
|
'dict.bulk.selectedCount_few': 'Выбрано {{count}} из {{total}}',
|
||||||
|
'dict.bulk.selectedCount_many': 'Выбрано {{count}} из {{total}}',
|
||||||
|
'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}',
|
||||||
|
'dict.bulk.clearSelection': 'Снять выделение',
|
||||||
|
'dict.bulk.closeAction': 'Закрыть выбранные',
|
||||||
|
'dict.bulk.confirmTitle': 'Массовое закрытие записей',
|
||||||
|
'dict.bulk.confirmBody':
|
||||||
|
'{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.',
|
||||||
|
'dict.bulk.confirmAction': 'Закрыть {{count}}',
|
||||||
|
'dict.bulk.resultClosed': 'Закрыто: {{count}}',
|
||||||
|
'dict.bulk.resultSkipped': 'Пропущено (уже закрыто или не найдено): {{count}}',
|
||||||
|
'dict.bulk.resultErrors': 'Ошибок: {{count}}',
|
||||||
|
'dict.bulk.dismiss': 'Готово',
|
||||||
'form.identity': 'Идентификация',
|
'form.identity': 'Идентификация',
|
||||||
'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»',
|
'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»',
|
||||||
'form.tabs.identity': 'Идентификация',
|
'form.tabs.identity': 'Идентификация',
|
||||||
@@ -410,6 +426,20 @@ i18n
|
|||||||
'Record "{{key}}" will be marked as closed (valid_to = now). Reversible by creating a new version.',
|
'Record "{{key}}" will be marked as closed (valid_to = now). Reversible by creating a new version.',
|
||||||
'dict.confirmClose.reason': 'Reason (optional)',
|
'dict.confirmClose.reason': 'Reason (optional)',
|
||||||
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
|
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
|
||||||
|
'dict.bulk.selectAll': 'Select all on page',
|
||||||
|
'dict.bulk.selectRow': 'Select record',
|
||||||
|
'dict.bulk.selectedCount_one': '{{count}} of {{total}} selected',
|
||||||
|
'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected',
|
||||||
|
'dict.bulk.clearSelection': 'Clear selection',
|
||||||
|
'dict.bulk.closeAction': 'Close selected',
|
||||||
|
'dict.bulk.confirmTitle': 'Bulk close records',
|
||||||
|
'dict.bulk.confirmBody':
|
||||||
|
'{{count}} record(s) will be marked as closed (valid_to = now). Reversible by creating a new version. Each record is closed individually — if some are already closed, others will still be processed.',
|
||||||
|
'dict.bulk.confirmAction': 'Close {{count}}',
|
||||||
|
'dict.bulk.resultClosed': 'Closed: {{count}}',
|
||||||
|
'dict.bulk.resultSkipped': 'Skipped (already closed or not found): {{count}}',
|
||||||
|
'dict.bulk.resultErrors': 'Errors: {{count}}',
|
||||||
|
'dict.bulk.dismiss': 'Done',
|
||||||
'form.identity': 'Identity',
|
'form.identity': 'Identity',
|
||||||
'form.idSourceNote': 'Business key auto-derived from "{{field}}" field',
|
'form.idSourceNote': 'Business key auto-derived from "{{field}}" field',
|
||||||
'form.tabs.identity': 'Identity',
|
'form.tabs.identity': 'Identity',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
@@ -21,10 +22,10 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@nstart/ui'
|
} 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 { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
|
||||||
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
import { useBulkCloseRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||||
import type { CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
@@ -97,6 +98,7 @@ function DictionaryDetail() {
|
|||||||
const createMut = useCreateRecord(name)
|
const createMut = useCreateRecord(name)
|
||||||
const updateMut = useUpdateRecord(name)
|
const updateMut = useUpdateRecord(name)
|
||||||
const closeMut = useCloseRecord(name)
|
const closeMut = useCloseRecord(name)
|
||||||
|
const bulkCloseMut = useBulkCloseRecords(name)
|
||||||
|
|
||||||
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||||||
const [closeReason, setCloseReason] = useState('')
|
const [closeReason, setCloseReason] = useState('')
|
||||||
@@ -104,6 +106,15 @@ function DictionaryDetail() {
|
|||||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
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 активны.
|
// Search & scope filter — URL-derived. Empty/missing = все scope активны.
|
||||||
const search = urlSearch.q ?? ''
|
const search = urlSearch.q ?? ''
|
||||||
const scopeFilter = useMemo<Set<DataScope>>(
|
const scopeFilter = useMemo<Set<DataScope>>(
|
||||||
@@ -178,6 +189,85 @@ function DictionaryDetail() {
|
|||||||
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
||||||
const filteredCount = filteredRecords.length
|
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
|
// Schema-driven extra columns: enum + reference поля — самые info-dense
|
||||||
// не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
|
// не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
|
||||||
// строку (с businessKey + name + scope + extras + дата + actions = 6
|
// строку (с businessKey + name + scope + extras + дата + actions = 6
|
||||||
@@ -383,23 +473,50 @@ function DictionaryDetail() {
|
|||||||
{filteredRecords.length === 0 ? (
|
{filteredRecords.length === 0 ? (
|
||||||
<EmptyState title={t('dict.filter.noMatches')} />
|
<EmptyState title={t('dict.filter.noMatches')} />
|
||||||
) : (
|
) : (
|
||||||
<Panel>
|
<>
|
||||||
<Table>
|
{selection.size > 0 && (
|
||||||
<TableHead>
|
<BulkSelectionToolbar
|
||||||
<TableRow>
|
count={selection.size}
|
||||||
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
totalVisible={filteredCount}
|
||||||
<TableHeaderCell>name / code</TableHeaderCell>
|
onBulkClose={openBulkClose}
|
||||||
{extraColumns.map((c) => (
|
onClear={clearSelection}
|
||||||
<TableHeaderCell key={c.key}>{c.label}</TableHeaderCell>
|
/>
|
||||||
))}
|
)}
|
||||||
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
|
<Panel>
|
||||||
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
|
<Table>
|
||||||
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
|
<TableHead>
|
||||||
</TableRow>
|
<TableRow>
|
||||||
</TableHead>
|
<TableHeaderCell>
|
||||||
<TableBody>
|
<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) => (
|
{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>
|
<TableCell>
|
||||||
<span className="font-mono text-2xs">{r.businessKey}</span>
|
<span className="font-mono text-2xs">{r.businessKey}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -466,9 +583,10 @@ function DictionaryDetail() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -602,6 +720,185 @@ function DictionaryDetail() {
|
|||||||
onApply={handleAoiApply}
|
onApply={handleAoiApply}
|
||||||
initial={aoi?.geojson ?? null}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk close: закрыть N записей одним запросом. Партиционирование per-record:
|
||||||
|
* если 3 из 10 уже закрыты или не существуют — другие 7 закроются.
|
||||||
|
*
|
||||||
|
* <p>Лимит 500 — защита от случайного UI-bug или копи-паст огромного списка.
|
||||||
|
*
|
||||||
|
* @param businessKeys список business_key для закрытия (1..500)
|
||||||
|
* @param at момент close (default now)
|
||||||
|
* @param reason бизнес-причина (опционально, идёт в audit log + outbox event)
|
||||||
|
*/
|
||||||
|
public record BulkCloseRequest(
|
||||||
|
@NotEmpty
|
||||||
|
@Size(max = 500, message = "bulk close принимает максимум 500 записей за запрос")
|
||||||
|
List<String> businessKeys,
|
||||||
|
OffsetDateTime at,
|
||||||
|
String reason) {}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат bulk close: per-record success/skip/error. Frontend показывает
|
||||||
|
* сводку "закрыто 7 из 10, 3 пропущено".
|
||||||
|
*
|
||||||
|
* @param closed успешно закрытые business_key
|
||||||
|
* @param skipped business_key которые уже неактивны (already_closed) или
|
||||||
|
* не существуют (not_found) — не error, ожидаемая ситуация при
|
||||||
|
* повторном клике или одновременной работе двух админов
|
||||||
|
* @param errors неожиданные ошибки (DB exception, validation fail) с reason
|
||||||
|
*/
|
||||||
|
public record BulkCloseResponse(
|
||||||
|
List<String> closed,
|
||||||
|
List<SkippedItem> skipped,
|
||||||
|
List<ErrorItem> errors) {
|
||||||
|
|
||||||
|
public record SkippedItem(String businessKey, String reason) {}
|
||||||
|
public record ErrorItem(String businessKey, String reason) {}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk операции над records. Каждый record закрывается в своей транзакции
|
||||||
|
* (через инжект {@link DictionaryRecordService}, чтобы Spring AOP proxy
|
||||||
|
* подхватил {@code @Transactional} на каждом вызове). Если 3 из 10 уже
|
||||||
|
* закрыты — остальные 7 закроются. Atomic guarantee только per-record,
|
||||||
|
* не на весь bulk.
|
||||||
|
*
|
||||||
|
* <p>Не @Transactional само по себе — иначе единственная транзакция накроет
|
||||||
|
* весь цикл и любая ошибка откатит всё, что мы НЕ хотим.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class BulkRecordService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BulkRecordService.class);
|
||||||
|
|
||||||
|
private final DictionaryRecordService recordService;
|
||||||
|
|
||||||
|
public BulkRecordService(DictionaryRecordService recordService) {
|
||||||
|
this.recordService = recordService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрыть N records по business_key. Дубликаты в списке дедуплицируются.
|
||||||
|
* Empty/blank business_key пропускается (попадает в skipped с reason=invalid_key).
|
||||||
|
*/
|
||||||
|
public BulkCloseResponse bulkClose(String dictionaryName, BulkCloseRequest req) {
|
||||||
|
List<String> closed = new ArrayList<>();
|
||||||
|
List<BulkCloseResponse.SkippedItem> skipped = new ArrayList<>();
|
||||||
|
List<BulkCloseResponse.ErrorItem> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
// Дедуп: если admin кликнул bulk close на одном records'е дважды —
|
||||||
|
// не пытаться закрыть его повторно во второй итерации.
|
||||||
|
var seen = new java.util.LinkedHashSet<String>();
|
||||||
|
for (String bk : req.businessKeys()) {
|
||||||
|
if (bk == null || bk.isBlank()) {
|
||||||
|
skipped.add(new BulkCloseResponse.SkippedItem(String.valueOf(bk), "invalid_key"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!seen.add(bk)) {
|
||||||
|
// Дубликат внутри batch — silently skip, не error
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
recordService.close(dictionaryName, bk, req.at(), req.reason());
|
||||||
|
closed.add(bk);
|
||||||
|
} catch (OrdinisException e) {
|
||||||
|
// record_not_active = повторный close или race с другим админом
|
||||||
|
if ("record_not_active".equals(e.getCode())) {
|
||||||
|
skipped.add(new BulkCloseResponse.SkippedItem(bk, "already_closed"));
|
||||||
|
} else {
|
||||||
|
errors.add(new BulkCloseResponse.ErrorItem(bk, e.getCode() + ": " + e.getMessage()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Bulk close: unexpected error для business_key={} в словаре {}", bk, dictionaryName, e);
|
||||||
|
errors.add(new BulkCloseResponse.ErrorItem(bk, e.getClass().getSimpleName() + ": " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Bulk close в {} : closed={} skipped={} errors={}",
|
||||||
|
dictionaryName, closed.size(), skipped.size(), errors.size());
|
||||||
|
|
||||||
|
return new BulkCloseResponse(closed, skipped, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -2,9 +2,12 @@ package cloud.nstart.terravault.ordinis.restapi.web;
|
|||||||
|
|
||||||
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse;
|
import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.BulkRecordService;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -28,14 +31,17 @@ import java.util.List;
|
|||||||
public class DictionaryRecordController {
|
public class DictionaryRecordController {
|
||||||
|
|
||||||
private final DictionaryRecordService service;
|
private final DictionaryRecordService service;
|
||||||
|
private final BulkRecordService bulkService;
|
||||||
private final DictionaryDefinitionService definitionService;
|
private final DictionaryDefinitionService definitionService;
|
||||||
private final ScopeContext scopeContext;
|
private final ScopeContext scopeContext;
|
||||||
|
|
||||||
public DictionaryRecordController(
|
public DictionaryRecordController(
|
||||||
DictionaryRecordService service,
|
DictionaryRecordService service,
|
||||||
|
BulkRecordService bulkService,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
ScopeContext scopeContext) {
|
ScopeContext scopeContext) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
|
this.bulkService = bulkService;
|
||||||
this.definitionService = definitionService;
|
this.definitionService = definitionService;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
}
|
}
|
||||||
@@ -91,6 +97,19 @@ public class DictionaryRecordController {
|
|||||||
service.close(name, businessKey, at, reason);
|
service.close(name, businessKey, at, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk close: закрыть N записей одним запросом. Per-record транзакция —
|
||||||
|
* partial success возможен (e.g. 7 из 10 закрылись, 3 уже были закрыты).
|
||||||
|
* Используется multi-select toolbar в admin UI.
|
||||||
|
*/
|
||||||
|
@PostMapping("/bulk-close")
|
||||||
|
public BulkCloseResponse bulkClose(
|
||||||
|
@PathVariable String name,
|
||||||
|
@Valid @RequestBody BulkCloseRequest req) {
|
||||||
|
requireWriteAccess(name);
|
||||||
|
return bulkService.bulkClose(name, req);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 404 если scope словаря недоступен текущему пользователю — намеренно скрываем
|
* 404 если scope словаря недоступен текущему пользователю — намеренно скрываем
|
||||||
* существование записи (security through obscurity), как делает read-api.
|
* существование записи (security through obscurity), как делает read-api.
|
||||||
|
|||||||
Reference in New Issue
Block a user