feat: add safe owner-only migration reset (#1682)
* feat: add safe company migration reset * fix: harden company reset eligibility * fix: close company reset compliance gaps * test: fix migration reset pg-real probes * fix: preserve migration archive access * docs: explain migration numbering continuity * fix: block reset with VAT workflow state * fix: block externally staged reset data * fix: address migration reset review findings * fix: clear stale migration archive estimate * fix: retry migration archive estimates
This commit is contained in:
@@ -20,33 +20,33 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import type { ApiResponse, ArchiveEstimate } from '@/types'
|
||||
import { Download, Loader2 } from 'lucide-react'
|
||||
|
||||
type Scope = 'all' | 'period'
|
||||
|
||||
interface EstimateResponse {
|
||||
total_bytes: number
|
||||
document_bytes: number
|
||||
document_count: number
|
||||
size_limit_bytes: number
|
||||
within_limit: boolean
|
||||
}
|
||||
type ArchiveMode = 'active-company' | 'migration-reset-source'
|
||||
|
||||
const LAST_DOWNLOAD_STORAGE_KEY = 'Accounted:last-backup-download'
|
||||
|
||||
/**
|
||||
* Direct download of the complete company archive (SIE + reports + all
|
||||
* supporting documents) as a ZIP, via GET /api/reports/full-archive.
|
||||
* The route is owner/admin-only; the caller gates the entry point.
|
||||
* supporting documents) as a ZIP. The normal mode uses the owner/admin-only
|
||||
* full-archive route; migration-reset mode uses its owner-only retained-source
|
||||
* route without making that archived company active.
|
||||
*/
|
||||
export function FullArchiveDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode = 'active-company',
|
||||
companyId: explicitCompanyId,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
mode?: ArchiveMode
|
||||
companyId?: string
|
||||
}) {
|
||||
const t = useTranslations('import')
|
||||
const tCompany = useTranslations('settings_company')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
@@ -55,14 +55,22 @@ export function FullArchiveDialog({
|
||||
const [scope, setScope] = useState<Scope>('all')
|
||||
const [periodId, setPeriodId] = useState<string | null>(null)
|
||||
const [includeDocuments, setIncludeDocuments] = useState(true)
|
||||
const [estimate, setEstimate] = useState<EstimateResponse | null>(null)
|
||||
const [estimate, setEstimate] = useState<ArchiveEstimate | null>(null)
|
||||
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [lastDownloadedAt, setLastDownloadedAt] = useState<string | null>(null)
|
||||
const isMigrationResetSource = mode === 'migration-reset-source'
|
||||
const archiveCompanyId = isMigrationResetSource
|
||||
? explicitCompanyId ?? company?.id
|
||||
: company?.id
|
||||
|
||||
const storageKey = useMemo(
|
||||
() => (company ? `${LAST_DOWNLOAD_STORAGE_KEY}:${company.id}` : null),
|
||||
[company]
|
||||
() => archiveCompanyId
|
||||
? isMigrationResetSource
|
||||
? `${LAST_DOWNLOAD_STORAGE_KEY}:migration-reset:${archiveCompanyId}`
|
||||
: `${LAST_DOWNLOAD_STORAGE_KEY}:${archiveCompanyId}`
|
||||
: null,
|
||||
[archiveCompanyId, isMigrationResetSource]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,14 +79,22 @@ export function FullArchiveDialog({
|
||||
}, [storageKey, open])
|
||||
|
||||
const archiveUrl = useMemo(() => {
|
||||
if (isMigrationResetSource) {
|
||||
if (!archiveCompanyId) return ''
|
||||
const params = new URLSearchParams()
|
||||
if (!includeDocuments) params.set('include_documents', 'false')
|
||||
const query = params.toString()
|
||||
return `/api/company/${archiveCompanyId}/migration-reset/archive${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ scope })
|
||||
if (scope === 'period' && periodId) params.set('period_id', periodId)
|
||||
if (!includeDocuments) params.set('include_documents', 'false')
|
||||
return `/api/reports/full-archive?${params.toString()}`
|
||||
}, [scope, periodId, includeDocuments])
|
||||
}, [archiveCompanyId, includeDocuments, isMigrationResetSource, periodId, scope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || (scope === 'period' && !periodId)) {
|
||||
if (!open || !archiveUrl || (!isMigrationResetSource && scope === 'period' && !periodId)) {
|
||||
setEstimate(null)
|
||||
return
|
||||
}
|
||||
@@ -87,10 +103,11 @@ export function FullArchiveDialog({
|
||||
setEstimate(null)
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${archiveUrl}&estimate=1`)
|
||||
const separator = archiveUrl.includes('?') ? '&' : '?'
|
||||
const res = await fetch(`${archiveUrl}${separator}estimate=1`)
|
||||
if (!res.ok) return
|
||||
const { data } = (await res.json()) as { data: EstimateResponse }
|
||||
if (!cancelled) setEstimate(data)
|
||||
const { data } = (await res.json()) as ApiResponse<ArchiveEstimate>
|
||||
if (!cancelled && data) setEstimate(data)
|
||||
} catch {
|
||||
// leave estimate null; the user can still attempt the download
|
||||
} finally {
|
||||
@@ -100,10 +117,10 @@ export function FullArchiveDialog({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, archiveUrl, scope, periodId])
|
||||
}, [open, archiveUrl, isMigrationResetSource, scope, periodId])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (scope === 'period' && !periodId) return
|
||||
if (!archiveUrl || (!isMigrationResetSource && scope === 'period' && !periodId)) return
|
||||
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
@@ -114,21 +131,27 @@ export function FullArchiveDialog({
|
||||
const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null
|
||||
toast({
|
||||
title: t('archive_toast_too_large_title'),
|
||||
description: sizeMb
|
||||
? t('archive_toast_too_large_with_size', { size: sizeMb })
|
||||
: t('archive_toast_too_large_generic'),
|
||||
description: isMigrationResetSource
|
||||
? sizeMb
|
||||
? tCompany('reset_archive_too_large_with_size', { size: sizeMb })
|
||||
: tCompany('reset_archive_too_large')
|
||||
: sizeMb
|
||||
? t('archive_toast_too_large_with_size', { size: sizeMb })
|
||||
: t('archive_toast_too_large_generic'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('archive_toast_failed'))
|
||||
throw new Error(readArchiveError(body, t('archive_toast_failed')))
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
const contentDisposition = res.headers.get('Content-Disposition') || ''
|
||||
const match = contentDisposition.match(/filename="?([^";]+)"?/)
|
||||
const filename = match?.[1] || 'arkiv.zip'
|
||||
const filename = match?.[1] || (isMigrationResetSource
|
||||
? 'migration_reset_archive.zip'
|
||||
: 'arkiv.zip')
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
@@ -158,43 +181,66 @@ export function FullArchiveDialog({
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}, [archiveUrl, scope, periodId, storageKey, toast, t, errorLocale])
|
||||
}, [
|
||||
archiveUrl,
|
||||
errorLocale,
|
||||
isMigrationResetSource,
|
||||
periodId,
|
||||
scope,
|
||||
storageKey,
|
||||
t,
|
||||
tCompany,
|
||||
toast,
|
||||
])
|
||||
|
||||
const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments
|
||||
const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!periodId)
|
||||
const plannedSizeBytes = estimate
|
||||
? includeDocuments
|
||||
? estimate.total_bytes
|
||||
: Math.max(0, estimate.total_bytes - estimate.document_bytes)
|
||||
: 0
|
||||
const isOverLimit = !!estimate && plannedSizeBytes > estimate.size_limit_bytes
|
||||
const canDownload = !isDownloading
|
||||
&& !isOverLimit
|
||||
&& (isMigrationResetSource || scope === 'all' || !!periodId)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{t('export_archive_title')}
|
||||
{isMigrationResetSource
|
||||
? tCompany('reset_archive_download_title')
|
||||
: t('export_archive_title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[13px] leading-relaxed">
|
||||
{t('archive_dialog_description')}
|
||||
{isMigrationResetSource
|
||||
? tCompany('reset_archive_download_description')
|
||||
: t('archive_dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<SegmentedControl
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
aria-label={t('archive_scope_label')}
|
||||
options={[
|
||||
{ value: 'all', label: t('archive_scope_all') },
|
||||
{ value: 'period', label: t('archive_scope_period') },
|
||||
]}
|
||||
/>
|
||||
{/* The two scopes count different document sets (all documents vs
|
||||
only those linked to posted vouchers in the year), so a company
|
||||
with unlinked inbox receipts sees very different counts. Say so,
|
||||
or the gap reads as a pagination bug. */}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{scope === 'all' ? t('archive_scope_all_note') : t('archive_scope_period_note')}
|
||||
</p>
|
||||
</div>
|
||||
{!isMigrationResetSource ? (
|
||||
<div className="space-y-2">
|
||||
<SegmentedControl
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
aria-label={t('archive_scope_label')}
|
||||
options={[
|
||||
{ value: 'all', label: t('archive_scope_all') },
|
||||
{ value: 'period', label: t('archive_scope_period') },
|
||||
]}
|
||||
/>
|
||||
{/* The two scopes count different document sets (all documents vs
|
||||
only those linked to posted vouchers in the year), so a company
|
||||
with unlinked inbox receipts sees very different counts. Say so,
|
||||
or the gap reads as a pagination bug. */}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{scope === 'all' ? t('archive_scope_all_note') : t('archive_scope_period_note')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === 'period' && (
|
||||
{!isMigrationResetSource && scope === 'period' && (
|
||||
<FiscalYearSelector
|
||||
value={periodId}
|
||||
onChange={setPeriodId}
|
||||
@@ -225,7 +271,7 @@ export function FullArchiveDialog({
|
||||
<>
|
||||
{t('archive_estimated_size')}{' '}
|
||||
<strong className="font-medium tabular-nums text-foreground">
|
||||
{formatBytes(estimate.total_bytes)}
|
||||
{formatBytes(plannedSizeBytes)}
|
||||
</strong>{' '}
|
||||
({estimate.document_count}{' '}
|
||||
{estimate.document_count === 1
|
||||
@@ -246,7 +292,11 @@ export function FullArchiveDialog({
|
||||
|
||||
{isOverLimit && (
|
||||
<AttnLine>
|
||||
{t('archive_over_limit', { limit: formatBytes(estimate!.size_limit_bytes) })}
|
||||
{isMigrationResetSource
|
||||
? tCompany('reset_archive_over_limit', {
|
||||
limit: formatBytes(estimate!.size_limit_bytes),
|
||||
})
|
||||
: t('archive_over_limit', { limit: formatBytes(estimate!.size_limit_bytes) })}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,3 +328,14 @@ function formatBytes(bytes: number): string {
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function readArchiveError(body: unknown, fallback: string): string {
|
||||
if (!body || typeof body !== 'object') return fallback
|
||||
const error = (body as { error?: unknown }).error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { CompanyMigrationResetDialog } from '@/components/settings/CompanyMigrationResetDialog'
|
||||
import { CompanyMigrationArchiveRow } from '@/components/settings/CompanyMigrationArchiveRow'
|
||||
|
||||
const branding = getBranding()
|
||||
|
||||
@@ -48,6 +50,7 @@ export function CompanyDangerZone() {
|
||||
const { company, role } = useCompany()
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [showResetDialog, setShowResetDialog] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
@@ -89,7 +92,21 @@ export function CompanyDangerZone() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompanyMigrationArchiveRow companyId={company.id} />
|
||||
|
||||
<SettingsDangerZone label={t('danger_heading')}>
|
||||
<SettingsRow label={t('reset_row_label')}>
|
||||
<SettingsRowNote>{t('reset_row_note')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80"
|
||||
>
|
||||
{t('reset_row_action')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t('danger_button')}
|
||||
borderless
|
||||
@@ -173,6 +190,13 @@ export function CompanyDangerZone() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CompanyMigrationResetDialog
|
||||
companyId={company.id}
|
||||
companyName={company.name}
|
||||
open={showResetDialog}
|
||||
onOpenChange={setShowResetDialog}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { FullArchiveDialog } from '@/components/import/FullArchiveDialog'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import type { ApiResponse, ArchiveEstimate } from '@/types'
|
||||
|
||||
const ESTIMATE_RETRY_DELAY_MS = 1_000
|
||||
const ESTIMATE_MAX_ATTEMPTS = 3
|
||||
|
||||
export function CompanyMigrationArchiveRow({ companyId }: { companyId: string }) {
|
||||
const t = useTranslations('settings_company')
|
||||
const [loadedEstimate, setLoadedEstimate] = useState<{
|
||||
companyId: string
|
||||
value: ArchiveEstimate
|
||||
} | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const estimate = loadedEstimate?.companyId === companyId
|
||||
? loadedEstimate.value
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const scheduleRetry = (attempt: number) => {
|
||||
if (cancelled || attempt >= ESTIMATE_MAX_ATTEMPTS) return
|
||||
retryTimer = setTimeout(() => {
|
||||
void loadEstimate(attempt + 1)
|
||||
}, ESTIMATE_RETRY_DELAY_MS)
|
||||
}
|
||||
|
||||
const loadEstimate = async (attempt: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/company/${companyId}/migration-reset/archive?estimate=1`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 || response.status === 429) scheduleRetry(attempt)
|
||||
return
|
||||
}
|
||||
const body = await response.json() as ApiResponse<ArchiveEstimate>
|
||||
if (!cancelled && body.data) {
|
||||
setLoadedEstimate({ companyId, value: body.data })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||
scheduleRetry(attempt)
|
||||
}
|
||||
}
|
||||
|
||||
void loadEstimate(1)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [companyId])
|
||||
|
||||
if (!estimate) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup label={t('reset_archive_group_label')}>
|
||||
<SettingsRow label={t('reset_archive_row_label')} borderless>
|
||||
<SettingsRowNote>
|
||||
{t('reset_archive_row_note', { count: estimate.document_count })}
|
||||
</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-sm font-medium text-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground/70"
|
||||
>
|
||||
{t('reset_archive_row_action')}
|
||||
</button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<FullArchiveDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
mode="migration-reset-source"
|
||||
companyId={companyId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertTriangle, Archive, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import {
|
||||
COMPANY_MIGRATION_RESET_COUNT_KEYS,
|
||||
type CompanyMigrationResetBlocker,
|
||||
type CompanyMigrationResetEligibility,
|
||||
} from '@/types'
|
||||
|
||||
interface CompanyMigrationResetDialogProps {
|
||||
companyId: string
|
||||
companyName: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const branding = getBranding()
|
||||
|
||||
function readApiError(body: unknown, fallback: string): string {
|
||||
if (!body || typeof body !== 'object') return fallback
|
||||
const error = (body as { error?: unknown }).error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function CompanyMigrationResetDialog({
|
||||
companyId,
|
||||
companyName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CompanyMigrationResetDialogProps) {
|
||||
const t = useTranslations('settings_company')
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { formatDateLong } = useFormat()
|
||||
const loadFailedMessage = t('reset_load_failed')
|
||||
const [eligibility, setEligibility] = useState<CompanyMigrationResetEligibility | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isResetting, setIsResetting] = useState(false)
|
||||
const [reason, setReason] = useState('')
|
||||
const [confirmName, setConfirmName] = useState('')
|
||||
const [confirmedNoFilings, setConfirmedNoFilings] = useState(false)
|
||||
const [confirmedArchive, setConfirmedArchive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let cancelled = false
|
||||
async function loadEligibility() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
setEligibility(null)
|
||||
try {
|
||||
const response = await fetch(`/api/company/${companyId}/migration-reset`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(readApiError(body, loadFailedMessage))
|
||||
}
|
||||
if (!cancelled) setEligibility(body.data as CompanyMigrationResetEligibility)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoadError(
|
||||
error instanceof Error ? getUserErrorMessage(error) : loadFailedMessage,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadEligibility()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [companyId, loadFailedMessage, open])
|
||||
|
||||
function resetForm() {
|
||||
setEligibility(null)
|
||||
setLoadError(null)
|
||||
setReason('')
|
||||
setConfirmName('')
|
||||
setConfirmedNoFilings(false)
|
||||
setConfirmedArchive(false)
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (isResetting) return
|
||||
onOpenChange(nextOpen)
|
||||
if (!nextOpen) resetForm()
|
||||
}
|
||||
|
||||
function blockerMessage(blocker: CompanyMigrationResetBlocker): string {
|
||||
switch (blocker.code) {
|
||||
case 'migration_window_expired':
|
||||
return t('reset_blocker_window', {
|
||||
date: eligibility ? formatDateLong(eligibility.window_ends_at) : '',
|
||||
})
|
||||
case 'sandbox_company':
|
||||
return t('reset_blocker_sandbox')
|
||||
case 'locked_or_closed_periods':
|
||||
return t('reset_blocker_periods', { count: blocker.count })
|
||||
case 'journal_entries_exist':
|
||||
case 'non_import_committed_entries':
|
||||
return t('reset_blocker_entries', { count: blocker.count })
|
||||
case 'voucher_sequence_state_exists':
|
||||
return t('reset_blocker_sequences', { count: blocker.count })
|
||||
case 'invoice_records_exist':
|
||||
return t('reset_blocker_invoices', { count: blocker.count })
|
||||
case 'authority_submission_detected':
|
||||
return t('reset_blocker_filings', { count: blocker.count })
|
||||
case 'live_bank_connections':
|
||||
return t('reset_blocker_bank_connections', { count: blocker.count })
|
||||
case 'imports_in_progress':
|
||||
return t('reset_blocker_imports', { count: blocker.count })
|
||||
case 'active_integrations_or_schedules':
|
||||
return t('reset_blocker_automations', { count: blocker.count })
|
||||
case 'background_work_in_progress':
|
||||
return t('reset_blocker_background_work', { count: blocker.count })
|
||||
default:
|
||||
return t('reset_blocker_other')
|
||||
}
|
||||
}
|
||||
|
||||
function countLabel(key: (typeof COMPANY_MIGRATION_RESET_COUNT_KEYS)[number]): string {
|
||||
switch (key) {
|
||||
case 'journal_entries': return t('reset_count_journal_entries')
|
||||
case 'journal_entry_lines': return t('reset_count_journal_entry_lines')
|
||||
case 'committed_import_entries': return t('reset_count_committed_import_entries')
|
||||
case 'transactions': return t('reset_count_transactions')
|
||||
case 'fiscal_periods': return t('reset_count_fiscal_periods')
|
||||
case 'documents': return t('reset_count_documents')
|
||||
case 'voucher_sequences': return t('reset_count_voucher_sequences')
|
||||
case 'sie_imports': return t('reset_count_sie_imports')
|
||||
case 'bank_file_imports': return t('reset_count_bank_file_imports')
|
||||
case 'skattekonto_file_imports': return t('reset_count_skattekonto_file_imports')
|
||||
case 'bank_connections': return t('reset_count_bank_connections')
|
||||
case 'customers': return t('reset_count_customers')
|
||||
case 'suppliers': return t('reset_count_suppliers')
|
||||
case 'invoices': return t('reset_count_invoices')
|
||||
case 'supplier_invoices': return t('reset_count_supplier_invoices')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmationName = eligibility?.display_name ?? companyName
|
||||
const canReset = eligibility?.eligible === true
|
||||
&& reason.trim().length >= 20
|
||||
&& confirmName.trim() === confirmationName.trim()
|
||||
&& confirmedNoFilings
|
||||
&& confirmedArchive
|
||||
&& !isResetting
|
||||
|
||||
async function handleReset() {
|
||||
if (!canReset) return
|
||||
setIsResetting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/company/${companyId}/migration-reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
confirm_name: confirmName,
|
||||
reason,
|
||||
confirm_no_filed_declarations: confirmedNoFilings,
|
||||
confirm_retained_archive: confirmedArchive,
|
||||
}),
|
||||
})
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(readApiError(body, t('reset_failed_default')))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('reset_success_title'),
|
||||
description: t('reset_success_description'),
|
||||
})
|
||||
setIsResetting(false)
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
router.push('/import')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('reset_failed_title'),
|
||||
description: error instanceof Error
|
||||
? getUserErrorMessage(error)
|
||||
: t('reset_failed_default'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle data-ph-mask="">
|
||||
{t('reset_dialog_title', { companyName })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('reset_dialog_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('reset_checking')}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{loadError}
|
||||
</div>
|
||||
) : eligibility ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-3 rounded-lg border border-border bg-muted/40 p-4">
|
||||
<Archive className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">{t('reset_archive_title')}</p>
|
||||
<p className="text-muted-foreground">{t('reset_archive_description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{eligibility.blockers.length > 0 ? (
|
||||
<div className="space-y-2 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('reset_blocked_title')}
|
||||
</div>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{eligibility.blockers.map((blocker) => (
|
||||
<li key={blocker.code}>{blockerMessage(blocker)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">{t('reset_retained_heading')}</h3>
|
||||
<dl className="divide-y divide-border border-y border-border">
|
||||
{COMPANY_MIGRATION_RESET_COUNT_KEYS.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between py-2 text-sm">
|
||||
<dt className="text-muted-foreground">{countLabel(key)}</dt>
|
||||
<dd className="tabular-nums font-medium">{eligibility.counts[key] ?? 0}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{eligibility.eligible ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="migration-reset-reason">{t('reset_reason_label')}</Label>
|
||||
<Textarea
|
||||
id="migration-reset-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={t('reset_reason_placeholder')}
|
||||
maxLength={1000}
|
||||
data-ph-mask=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('reset_reason_help', { count: reason.trim().length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="migration-reset-no-filings"
|
||||
checked={confirmedNoFilings}
|
||||
onCheckedChange={(checked) => setConfirmedNoFilings(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="migration-reset-no-filings" className="font-normal leading-5">
|
||||
{t('reset_confirm_no_filings', { appName: branding.appName })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="migration-reset-retained"
|
||||
checked={confirmedArchive}
|
||||
onCheckedChange={(checked) => setConfirmedArchive(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="migration-reset-retained" className="font-normal leading-5">
|
||||
{t('reset_confirm_archive')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label data-ph-mask="" htmlFor="migration-reset-name">
|
||||
{t.rich('reset_confirm_name', {
|
||||
companyName: confirmationName,
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="migration-reset-name"
|
||||
value={confirmName}
|
||||
onChange={(event) => setConfirmName(event.target.value)}
|
||||
placeholder={confirmationName}
|
||||
autoComplete="off"
|
||||
className="ph-no-capture"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isResetting}>
|
||||
{t('danger_cancel')}
|
||||
</Button>
|
||||
{eligibility?.eligible ? (
|
||||
<Button variant="destructive" onClick={handleReset} disabled={!canReset}>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('reset_resetting')}
|
||||
</>
|
||||
) : t('reset_submit')}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user