feat(dict): delete dictionary endpoint + UI button с cascade safety
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user