fix(admin-ui): top-5 critical review bugs (auth + AJV + TZ + idempotency + storage)

This commit is contained in:
Александр Зимин
2026-05-10 16:22:35 +00:00
parent f2614244bf
commit fc2fb35717
8 changed files with 292 additions and 72 deletions
@@ -30,7 +30,7 @@ import {
useRecords,
type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
@@ -38,7 +38,7 @@ import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDepend
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { nowIsoLocal } from '@/lib/dates'
import { nowIsoLocal, toDateTimeLocalInput, fromDateTimeLocalInput } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
@@ -138,6 +138,10 @@ function DictionaryDetail() {
}, [pendingDraftsQuery.data])
const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name)
// Per-form idempotency key. Stable между ререндерами для одной сессии edit
// modal'а: двойной submit (Enter + click) не создаст 2 record'а — backend
// deduplicate'ит по тому же ключу. Reset на onSuccess.
const [recordIdempotencyKey, resetRecordIdempotencyKey] = useFormIdempotencyKey()
const closeMut = useCloseRecord(name)
const bulkCloseMut = useBulkCloseRecords(name)
const bulkExportMut = useBulkExportRecords(name)
@@ -364,13 +368,30 @@ function DictionaryDetail() {
const handleSubmit = (req: CreateRecordRequest) => {
if (edit.kind === 'create') {
createMut.mutate(req, { onSuccess: () => setEdit({ kind: 'closed' }) })
createMut.mutate(
{ payload: req, idempotencyKey: recordIdempotencyKey },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
},
)
return
}
if (edit.kind === 'edit') {
updateMut.mutate(
{ businessKey: edit.record.businessKey, payload: req },
{ onSuccess: () => setEdit({ kind: 'closed' }) },
{
businessKey: edit.record.businessKey,
payload: req,
idempotencyKey: recordIdempotencyKey,
},
{
onSuccess: () => {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
},
)
}
}
@@ -519,18 +540,18 @@ function DictionaryDetail() {
<input
type="datetime-local"
className="px-2 py-1 rounded-sm border border-regolith bg-white text-2xs font-mono"
value={
timeTravelAt
? // datetime-local не принимает Z/offset — конвертируем в local.
new Date(timeTravelAt).toISOString().slice(0, 16)
: ''
}
// datetime-local требует local time без offset. toISOString() отдаёт
// UTC → у пользователя в +03:00 picker показывал значение на 3ч
// раньше выбранного, и на каждом редактировании round-trip drift'ил
// время. toDateTimeLocalInput / fromDateTimeLocalInput сохраняют
// wall-clock время через формат с явным local-TZ offset.
value={toDateTimeLocalInput(timeTravelAt)}
onChange={(e) => {
if (!e.target.value) {
setTimeTravelAt(undefined)
return
}
setTimeTravelAt(new Date(e.target.value).toISOString())
setTimeTravelAt(fromDateTimeLocalInput(e.target.value))
}}
aria-label={t('timeTravel.label')}
/>