feat(dict): 7-day retention auto-purge + inline restore UI
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
useAudit,
|
||||
useChangelog,
|
||||
useDictActiveSchemaDraft,
|
||||
useDictionariesDeleted,
|
||||
useDictionaryDetail,
|
||||
useDictPendingDrafts,
|
||||
useRecordRaw,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
useScheduledSummary,
|
||||
type RecordsFilter,
|
||||
} from '@/api/queries'
|
||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
|
||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useRestoreDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
|
||||
import { useCanMutate } from '@/auth/useCanMutate'
|
||||
import { useIsAdmin } from '@/auth/usePermissions'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -863,7 +864,14 @@ function DictionaryDetail() {
|
||||
)
|
||||
}
|
||||
if (detailQuery.error) {
|
||||
return <QueryErrorState error={detailQuery.error} onRetry={() => detailQuery.refetch()} />
|
||||
return (
|
||||
<DictionaryErrorOrSoftDeletedCard
|
||||
name={name}
|
||||
error={detailQuery.error}
|
||||
isAdmin={isAdmin}
|
||||
onRetry={() => detailQuery.refetch()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -2452,6 +2460,114 @@ function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client
|
||||
* <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
|
||||
* (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
|
||||
*/
|
||||
/**
|
||||
* Error state для route когда `useDictionaryDetail` returns 404 либо другой
|
||||
* error. Special-case: если dict в /dictionaries/deleted (Корзина) — show
|
||||
* inline restore card вместо generic error. 7-day retention window:
|
||||
* admin может restore в этот период, дальше DictionaryPurgeJob physically
|
||||
* removes (recovery только из DB backup).
|
||||
*
|
||||
* <p>Non-admin / dict не в Корзине → стандартный QueryErrorState.
|
||||
*/
|
||||
function DictionaryErrorOrSoftDeletedCard({
|
||||
name,
|
||||
error,
|
||||
isAdmin,
|
||||
onRetry,
|
||||
}: {
|
||||
name: string
|
||||
error: unknown
|
||||
isAdmin: boolean
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const restoreMut = useRestoreDictionary()
|
||||
// Дёргаем Корзину только если admin (non-admin получает 403 от backend).
|
||||
const deletedQ = useDictionariesDeleted(isAdmin)
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
const isNotFound = status === 404
|
||||
const softDeleted = deletedQ.data?.find((d) => d.name === name)
|
||||
|
||||
if (!isNotFound || !softDeleted) {
|
||||
return <QueryErrorState error={error} onRetry={onRetry} />
|
||||
}
|
||||
|
||||
// Сколько осталось до auto-purge (7-day retention).
|
||||
const deletedAt =
|
||||
(softDeleted as unknown as { deletedAt?: string; updatedAt?: string }).deletedAt
|
||||
?? softDeleted.updatedAt
|
||||
const deletedDate = deletedAt ? new Date(deletedAt) : null
|
||||
const purgeDate = deletedDate ? new Date(deletedDate.getTime() + 7 * 24 * 60 * 60 * 1000) : null
|
||||
const daysLeft = purgeDate
|
||||
? Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-12">
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-5 py-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<TrashIcon size={24} weight="regular" className="text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-title-md text-ink mb-1">
|
||||
{t('dict.deleted.title', { defaultValue: 'Справочник удалён' })}
|
||||
</h2>
|
||||
<p className="text-cell text-ink-2 mb-1">
|
||||
<strong>{softDeleted.name}</strong>
|
||||
{softDeleted.displayName ? ` — ${softDeleted.displayName}` : null}
|
||||
</p>
|
||||
<p className="text-cell text-ink-2">
|
||||
{daysLeft !== null && daysLeft > 0
|
||||
? t('dict.deleted.retentionLeft', {
|
||||
defaultValue:
|
||||
'Восстановить можно в течение {{days}} дн. — после этого записи будут безвозвратно удалены.',
|
||||
days: daysLeft,
|
||||
})
|
||||
: t('dict.deleted.retentionExpiringSoon', {
|
||||
defaultValue: 'Срок восстановления истекает — purge cron сработает в ближайшие часы.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-destructive/20">
|
||||
<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={() => navigate({ to: '/dictionaries' })}
|
||||
>
|
||||
{t('dict.deleted.backToList', { defaultValue: 'К списку справочников' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 px-4 rounded-md bg-accent text-on-accent text-cell font-medium hover:bg-accent/85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
restoreMut.mutate(name, {
|
||||
onSuccess: () => {
|
||||
// После restore — refetch detail + navigate back to dict.
|
||||
onRetry()
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={restoreMut.isPending}
|
||||
>
|
||||
{restoreMut.isPending
|
||||
? t('dict.deleted.restorePending', { defaultValue: 'Восстанавливаю…' })
|
||||
: t('dict.deleted.restore', { defaultValue: 'Восстановить справочник' })}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-cell text-mute pt-2 border-t border-destructive/20">
|
||||
{t('dict.deleted.adminOnly', {
|
||||
defaultValue: 'Восстановление справочника доступно только администратору.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function highlightJson(json: string): string {
|
||||
const escape = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
Reference in New Issue
Block a user