feat(dict): delete dictionary endpoint + UI button с cascade safety

This commit is contained in:
Александр Зимин
2026-06-05 09:47:05 +00:00
parent cdffa4261c
commit 5c6801f993
10 changed files with 437 additions and 4 deletions
+26
View File
@@ -136,6 +136,32 @@ export const useUpdateDictionary = () => {
})
}
/**
* Удаление справочника. Cascade на records + drafts + schema_drafts через
* FK ON DELETE CASCADE (migration 0030). Только admin role.
*
* <p>Backend errors:
* <ul>
* <li>403 {@code forbidden_role} — не admin.</li>
* <li>404 {@code dictionary_not_found} — нет либо вне scope.</li>
* <li>409 {@code has_dependents} — другой dict ссылается через
* x-references. message содержит список dependents. UI показывает
* их юзеру и предлагает либо убрать FK у зависимых, либо force.</li>
* </ul>
*/
export const useDeleteDictionary = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: { name: string; force?: boolean }): Promise<void> => {
const qs = params.force ? '?force=true' : ''
await apiClient.delete(`/dictionaries/${params.name}${qs}`)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dictionaries'] })
},
})
}
/**
* Create-record mutation. {@code idempotencyKey} — required, передаётся caller'ом
* (один на жизнь формы; см. {@link useFormIdempotencyKey}). Это защищает от
@@ -40,6 +40,11 @@ export type EditorInfoPanelProps = {
aoiActive?: boolean
onExport?: () => void
exportPending?: boolean
/** Удалить справочник — admin-only action. Caller гейтит видимость
* через `useIsAdmin()`. Cascade delete на records/drafts/audit
* (backend migration 0030 + DictionaryDefinitionService.delete). */
onDelete?: () => void
deletePending?: boolean
}
/** Optional banner slot — WorkflowBanner / status notices живут в
* left panel per user feedback ("плашки пусть в левой панели будут").
@@ -231,6 +236,18 @@ export function EditorInfoPanel({ detail, recordCount, actions, banner }: Editor
{t('aoi.button', { defaultValue: 'AOI фильтр…' })}
</button>
)}
{actions.onDelete && (
<button
type="button"
onClick={actions.onDelete}
disabled={actions.deletePending}
className="w-full h-8 rounded-md border border-mars/40 bg-surface text-mars text-cell flex items-center justify-center gap-1.5 transition-colors hover:border-mars hover:bg-mars/8 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actions.deletePending
? t('dict.delete.pending', { defaultValue: 'Удаляю…' })
: t('dict.delete.button', { defaultValue: 'Удалить справочник…' })}
</button>
)}
</div>
)}
</aside>
@@ -38,8 +38,9 @@ import {
useScheduledSummary,
type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
import { useCanMutate } from '@/auth/useCanMutate'
import { useIsAdmin } from '@/auth/usePermissions'
import { cn } from '@/lib/utils'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
@@ -223,6 +224,11 @@ function DictionaryDetail() {
const closeMut = useCloseRecord(name)
const bulkCloseMut = useBulkCloseRecords(name)
const bulkExportMut = useBulkExportRecords(name)
// Delete dict mutation — admin-only через UI button в InfoPanel.
const deleteMut = useDeleteDictionary()
const isAdmin = useIsAdmin()
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
const [closeReason, setCloseReason] = useState('')
@@ -908,6 +914,12 @@ function DictionaryDetail() {
// выбирает формат/scope/columns/encoding/delimiter.
onExport: () => setExportOpen(true),
exportPending: bulkExportMut.isPending,
// Удалить справочник — только админ. Cascade delete на
// records/drafts/audit (FK CASCADE migration 0030).
// Confirmation modal проверяет dependents через backend
// и предлагает force option если нужно.
onDelete: isAdmin ? () => setDeleteConfirmOpen(true) : undefined,
deletePending: deleteMut.isPending,
}}
/>
)}
@@ -1813,6 +1825,120 @@ function DictionaryDetail() {
approvalMode={approvalRequired}
/>
</Modal>
{/* Delete dict confirmation modal — admin-only. Cascade на
* records + drafts + schema_drafts через FK CASCADE (migration 0030).
* Backend проверяет incoming dependents через lineage и rejектит с
* 409 has_dependents если другой dict ссылается через x-references.
* UI показывает error text + предлагает force option. */}
<Modal
isOpen={deleteConfirmOpen}
onClose={() => {
if (deleteMut.isPending) return
setDeleteConfirmOpen(false)
setDeleteError(null)
}}
title={t('dict.delete.title', { defaultValue: 'Удалить справочник?' })}
maxWidth="max-w-lg"
>
<div className="space-y-4">
<p className="text-cell text-ink-2">
{t('dict.delete.warning', {
defaultValue:
'Будут удалены: ВСЕ записи справочника, drafts, schema-drafts, история версий. Эта операция необратима.',
})}
</p>
<div className="rounded-md border border-mars/30 bg-mars/8 px-3 py-2 text-cell">
<strong>{name}</strong>
{detailQuery.data?.displayName ? `${detailQuery.data.displayName}` : null}
</div>
{deleteError && (
<div className="rounded-md border border-mars/40 bg-mars/8 px-3 py-2 text-cell text-mars whitespace-pre-line">
{deleteError}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
onClick={() => {
setDeleteConfirmOpen(false)
setDeleteError(null)
}}
disabled={deleteMut.isPending}
>
{t('common.cancel', { defaultValue: 'Отмена' })}
</button>
<button
type="button"
className="h-9 px-4 rounded-md bg-mars text-surface text-cell font-medium hover:bg-mars/85 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
setDeleteError(null)
deleteMut.mutate(
{ name },
{
onSuccess: () => {
setDeleteConfirmOpen(false)
navigate({ to: '/dictionaries' })
},
onError: (err) => {
const code = (err as { response?: { data?: { code?: string } } })?.response
?.data?.code
const msg = (err as { response?: { data?: { message?: string } } })?.response
?.data?.message
if (code === 'has_dependents') {
setDeleteError(
(msg ?? 'Есть зависимые справочники.') +
'\n\n' +
t('dict.delete.forceHint', {
defaultValue:
'Можно повторить с force=true — но зависимые справочники будут ссылаться на удалённое (FK validation сломается).',
}),
)
} else {
setDeleteError(msg ?? String(err))
}
},
},
)
}}
disabled={deleteMut.isPending}
>
{deleteMut.isPending
? t('dict.delete.pending', { defaultValue: 'Удаляю…' })
: t('dict.delete.confirm', { defaultValue: 'Удалить безвозвратно' })}
</button>
{deleteError?.includes('has_dependents') ||
(deleteError &&
deleteError.toLowerCase().includes('зависимы')) ? (
<button
type="button"
className="h-9 px-4 rounded-md border border-mars/40 bg-surface text-mars text-cell hover:bg-mars/8 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
setDeleteError(null)
deleteMut.mutate(
{ name, force: true },
{
onSuccess: () => {
setDeleteConfirmOpen(false)
navigate({ to: '/dictionaries' })
},
onError: (err) => {
const msg = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message
setDeleteError(msg ?? String(err))
},
},
)
}}
disabled={deleteMut.isPending}
>
{t('dict.delete.force', { defaultValue: 'Удалить force (всё равно)' })}
</button>
) : null}
</div>
</div>
</Modal>
</div>
)
}