feat(records): bulk export CSV выбранных + pagination
Backend POST /api/v1/dictionaries/{name}/records/export-selected.csv:
принимает businessKeys[] (1..500), возвращает CSV с колонками
business_key, valid_from, valid_to, scope, data (JSONB inline).
Не-найденные ключи отдаются строкой с пустыми ячейками — admin видит
что ключ обработан, но данных нет. RFC 4180 экранирование.
requireReadAccess: для экспорта достаточно прав чтения (не write).
POST а не GET потому что >100 ключей не помещается в URL params
(Server: Request URI Too Long).
Frontend useBulkExportRecords: axios POST с responseType: blob →
temporary <a download> link → клик → revokeObjectURL через rAF.
Filename берётся из Content-Disposition header.
Toolbar: новая кнопка "Экспорт CSV" (variant=secondary) рядом с
"Закрыть выбранные" (variant=danger). Loading state через isPending.
Pagination для records list (PAGE_SIZE=50, client-side):
- visibleRecords = filteredRecords.slice(...)
- Footer "Записи 1–50 из 100, Стр. 1 из 2" с Prev/Next кнопками
- Auto-reset на page=0 при изменении filter/scope/AOI
- selectAll/visibleKeys теперь = только current page (WYSIWYG)
- При >50 записей видно навигацию, при <=50 footer скрыт
Прогон: 20/20 e2e + 60/60 rest-api + 89/89 admin-ui.
This commit is contained in:
@@ -123,6 +123,41 @@ export const useBulkCloseRecords = (dictionaryName: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export selected: выгрузить N выбранных записей в CSV. Backend
|
||||
* возвращает text/csv blob с auth header, фронтенд триггерит download
|
||||
* через temporary `<a>` link.
|
||||
*
|
||||
* POST (не GET): >100 ключей не помещается в URL params.
|
||||
*/
|
||||
export const useBulkExportRecords = (dictionaryName: string) => {
|
||||
return useMutation({
|
||||
mutationFn: async (businessKeys: string[]): Promise<void> => {
|
||||
const response = await apiClient.post(
|
||||
`/dictionaries/${dictionaryName}/records/export-selected.csv`,
|
||||
{ businessKeys },
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
// Извлекаем filename из Content-Disposition если есть
|
||||
const cd = response.headers['content-disposition'] as string | undefined
|
||||
const match = cd?.match(/filename="?([^";]+)"?/)
|
||||
const filename = match?.[1]
|
||||
?? `${dictionaryName}-selected-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
|
||||
// Trigger browser download через blob URL + temporary <a download>
|
||||
const url = window.URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// Через rAF чтобы download успел запуститься до revoke
|
||||
requestAnimationFrame(() => window.URL.revokeObjectURL(url))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// === Webhooks (v2) ===
|
||||
|
||||
export const useCreateWebhook = () => {
|
||||
|
||||
@@ -153,6 +153,11 @@ i18n
|
||||
'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}',
|
||||
'dict.bulk.clearSelection': 'Снять выделение',
|
||||
'dict.bulk.closeAction': 'Закрыть выбранные',
|
||||
'dict.bulk.exportAction': 'Экспорт CSV',
|
||||
'dict.page.prev': 'Назад',
|
||||
'dict.page.next': 'Вперёд',
|
||||
'dict.page.of': 'Стр. {{cur}} из {{total}}',
|
||||
'dict.page.range': 'Записи {{from}}–{{to}} из {{total}}',
|
||||
'dict.bulk.confirmTitle': 'Массовое закрытие записей',
|
||||
'dict.bulk.confirmBody':
|
||||
'{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.',
|
||||
@@ -432,6 +437,11 @@ i18n
|
||||
'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected',
|
||||
'dict.bulk.clearSelection': 'Clear selection',
|
||||
'dict.bulk.closeAction': 'Close selected',
|
||||
'dict.bulk.exportAction': 'Export CSV',
|
||||
'dict.page.prev': 'Prev',
|
||||
'dict.page.next': 'Next',
|
||||
'dict.page.of': 'Page {{cur}} of {{total}}',
|
||||
'dict.page.range': 'Records {{from}}–{{to}} of {{total}}',
|
||||
'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.',
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from '@nstart/ui'
|
||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon } from '@phosphor-icons/react'
|
||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
||||
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
|
||||
import { useBulkCloseRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||
import { useBulkCloseRecords, useBulkExportRecords, 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'
|
||||
@@ -99,6 +99,7 @@ function DictionaryDetail() {
|
||||
const updateMut = useUpdateRecord(name)
|
||||
const closeMut = useCloseRecord(name)
|
||||
const bulkCloseMut = useBulkCloseRecords(name)
|
||||
const bulkExportMut = useBulkExportRecords(name)
|
||||
|
||||
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||||
const [closeReason, setCloseReason] = useState('')
|
||||
@@ -189,11 +190,30 @@ function DictionaryDetail() {
|
||||
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
||||
const filteredCount = filteredRecords.length
|
||||
|
||||
// Client-side pagination — типичный словарь до 100 записей, server-side
|
||||
// пейджинг overkill. Page 0-indexed. PAGE_SIZE = 50 — достаточно чтобы
|
||||
// увидеть все основные dictionaries без листания.
|
||||
const PAGE_SIZE = 50
|
||||
const [page, setPage] = useState(0)
|
||||
const totalPages = Math.max(1, Math.ceil(filteredCount / PAGE_SIZE))
|
||||
|
||||
// При изменении filter — на page 0 (иначе page может оказаться вне диапазона)
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [search, scopeFilter, aoi])
|
||||
|
||||
const visibleRecords = useMemo(
|
||||
() => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
|
||||
[filteredRecords, page],
|
||||
)
|
||||
|
||||
// Каноничный список видимых ключей — для select-all и для очистки selection
|
||||
// от ключей которые больше не видны (filter изменился, запись закрылась).
|
||||
// С client-side pagination "видимые" = только текущая страница, чтобы
|
||||
// select-all чекбокс трогал ровно то что юзер видит на экране.
|
||||
const visibleKeys = useMemo(
|
||||
() => filteredRecords.map((r) => r.businessKey),
|
||||
[filteredRecords],
|
||||
() => visibleRecords.map((r) => r.businessKey),
|
||||
[visibleRecords],
|
||||
)
|
||||
|
||||
// Когда видимый набор сократился — выкидываем из selection невидимые ключи.
|
||||
@@ -246,6 +266,11 @@ function DictionaryDetail() {
|
||||
setBulkOpen(true)
|
||||
}
|
||||
|
||||
const handleBulkExport = () => {
|
||||
if (selection.size === 0) return
|
||||
bulkExportMut.mutate(Array.from(selection))
|
||||
}
|
||||
|
||||
const handleBulkClose = () => {
|
||||
if (selection.size === 0) return
|
||||
bulkCloseMut.mutate(
|
||||
@@ -479,7 +504,9 @@ function DictionaryDetail() {
|
||||
count={selection.size}
|
||||
totalVisible={filteredCount}
|
||||
onBulkClose={openBulkClose}
|
||||
onBulkExport={handleBulkExport}
|
||||
onClear={clearSelection}
|
||||
isExporting={bulkExportMut.isPending}
|
||||
/>
|
||||
)}
|
||||
<Panel>
|
||||
@@ -508,7 +535,7 @@ function DictionaryDetail() {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredRecords.map((r) => (
|
||||
{visibleRecords.map((r) => (
|
||||
<TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
@@ -586,6 +613,16 @@ function DictionaryDetail() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
{totalPages > 1 && (
|
||||
<RecordsPagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
totalCount={filteredCount}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -754,14 +791,18 @@ type BulkSelectionToolbarProps = {
|
||||
count: number
|
||||
totalVisible: number
|
||||
onBulkClose: () => void
|
||||
onBulkExport: () => void
|
||||
onClear: () => void
|
||||
isExporting: boolean
|
||||
}
|
||||
|
||||
function BulkSelectionToolbar({
|
||||
count,
|
||||
totalVisible,
|
||||
onBulkClose,
|
||||
onBulkExport,
|
||||
onClear,
|
||||
isExporting,
|
||||
}: BulkSelectionToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -784,14 +825,77 @@ function BulkSelectionToolbar({
|
||||
{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 className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
leftIcon={<DownloadSimpleIcon weight="bold" size={14} />}
|
||||
onClick={onBulkExport}
|
||||
loading={isExporting}
|
||||
>
|
||||
{t('dict.bulk.exportAction')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
leftIcon={<TrashIcon weight="bold" size={14} />}
|
||||
onClick={onBulkClose}
|
||||
>
|
||||
{t('dict.bulk.closeAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type RecordsPaginationProps = {
|
||||
page: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
pageSize: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
function RecordsPagination({
|
||||
page,
|
||||
totalPages,
|
||||
totalCount,
|
||||
pageSize,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: RecordsPaginationProps) {
|
||||
const { t } = useTranslation()
|
||||
const from = page * pageSize + 1
|
||||
const to = Math.min((page + 1) * pageSize, totalCount)
|
||||
return (
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 px-2">
|
||||
<span className="text-2xs text-carbon/70 tabular-nums">
|
||||
{t('dict.page.range', { from, to, total: totalCount })}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
leftIcon={<CaretLeftIcon weight="bold" size={14} />}
|
||||
onClick={onPrev}
|
||||
disabled={page <= 0}
|
||||
>
|
||||
{t('dict.page.prev')}
|
||||
</Button>
|
||||
<span className="tabular-nums text-2xs text-carbon/70">
|
||||
{t('dict.page.of', { cur: page + 1, total: totalPages })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
rightIcon={<CaretRightIcon weight="bold" size={14} />}
|
||||
onClick={onNext}
|
||||
disabled={page + 1 >= totalPages}
|
||||
>
|
||||
{t('dict.page.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user