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:
Zimin A.N.
2026-05-07 12:12:31 +03:00
parent 66a4f13942
commit 4252ad93a8
7 changed files with 286 additions and 24 deletions
@@ -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>
)
}