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
+35
View File
@@ -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 = () => {