'use client' import { useLocale, useTranslations } from 'next-intl' import { useCallback, useEffect, useMemo, useState } from 'react' import { AttnLine } from '@/components/ui/attn-line' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' import { useCompany } from '@/contexts/CompanyContext' import { Cloud, Download, Info, Loader2 } from 'lucide-react' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' import type { FiscalPeriod } from '@/types' import { getErrorMessage as getUserErrorMessage, type ErrorLocale, } from '@/lib/errors/get-error-message' const CloudBackupPanel = getSettingsPanel('cloud-backup') const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup') type Scope = 'all' | 'period' interface EstimateResponse { total_bytes: number document_bytes: number document_count: number size_limit_bytes: number within_limit: boolean } const LAST_DOWNLOAD_STORAGE_KEY = 'Accounted:last-backup-download' export function BackupDownloadForm() { const t = useTranslations('settings_backup_download') const errorLocale = useLocale() as ErrorLocale const { toast } = useToast() const { company } = useCompany() const [scope, setScope] = useState('all') const [includeDocuments, setIncludeDocuments] = useState(true) // null = the fiscal years are not known: still loading, or the read failed // (periodsError). A failed read must never render the confirmed-empty // "Inga räkenskapsår" option: the company usually has fiscal years, the // read just did not land. Scope "all" never touches this list, so the full // backup keeps working either way. const [periods, setPeriods] = useState(null) // detail === null: transient, so the line carries a retry. A detail sentence // means the user has to act (an expired session) and a retry cannot help. const [periodsError, setPeriodsError] = useState<{ detail: string | null } | null>(null) const [periodsReloadKey, setPeriodsReloadKey] = useState(0) const [selectedPeriodId, setSelectedPeriodId] = useState('') const [estimate, setEstimate] = useState(null) const [isLoadingEstimate, setIsLoadingEstimate] = useState(false) const [isDownloading, setIsDownloading] = useState(false) const [lastDownloadedAt, setLastDownloadedAt] = useState(null) const storageKey = useMemo( () => (company ? `${LAST_DOWNLOAD_STORAGE_KEY}:${company.id}` : null), [company] ) useEffect(() => { if (!storageKey) return setLastDownloadedAt(window.localStorage.getItem(storageKey)) }, [storageKey]) useEffect(() => { let cancelled = false async function loadPeriods() { setPeriodsError(null) try { const res = await fetch('/api/bookkeeping/fiscal-periods') if (!res.ok) { // Not-JSON bodies (an HTML error page, an empty 502) leave null, // and getErrorMessage falls back to the status map. const body = await res.json().catch(() => null) if (cancelled) return const sessionGone = res.status === 401 || res.status === 403 setPeriods(null) setPeriodsError({ detail: sessionGone ? getUserErrorMessage(body, { statusCode: res.status, locale: errorLocale }) : null, }) return } // A 200 whose body will not parse throws into the catch below; a 200 // without the list is a failed read too. Neither may become a // fabricated "Inga räkenskapsår". const body = await res.json() if (cancelled) return if (!Array.isArray(body?.data)) { setPeriods(null) setPeriodsError({ detail: null }) return } const sorted = body.data as FiscalPeriod[] setPeriods(sorted) if (sorted.length > 0) { setSelectedPeriodId((prev) => prev || sorted[0].id) } } catch { if (!cancelled) { setPeriods(null) setPeriodsError({ detail: null }) } } } void loadPeriods() return () => { cancelled = true } }, [periodsReloadKey, errorLocale]) const estimateUrl = useMemo(() => { const params = new URLSearchParams({ estimate: '1', scope }) if (scope === 'period' && selectedPeriodId) { params.set('period_id', selectedPeriodId) } if (!includeDocuments) { params.set('include_documents', 'false') } return `/api/reports/full-archive?${params.toString()}` }, [scope, selectedPeriodId, includeDocuments]) useEffect(() => { if (scope === 'period' && !selectedPeriodId) { setEstimate(null) return } let cancelled = false setIsLoadingEstimate(true) setEstimate(null) ;(async () => { try { const res = await fetch(estimateUrl) if (!res.ok) return const { data } = (await res.json()) as { data: EstimateResponse } if (!cancelled) setEstimate(data) } catch { // leave estimate null; we still let users attempt the download } finally { if (!cancelled) setIsLoadingEstimate(false) } })() return () => { cancelled = true } }, [estimateUrl, scope, selectedPeriodId]) const downloadUrl = useMemo(() => { const params = new URLSearchParams({ scope }) if (scope === 'period' && selectedPeriodId) { params.set('period_id', selectedPeriodId) } if (!includeDocuments) { params.set('include_documents', 'false') } return `/api/reports/full-archive?${params.toString()}` }, [scope, selectedPeriodId, includeDocuments]) const handleDownload = useCallback(async () => { if (scope === 'period' && !selectedPeriodId) return setIsDownloading(true) try { const res = await fetch(downloadUrl) if (!res.ok) { if (res.status === 413) { const body = await res.json().catch(() => ({})) const sizeMb = body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null toast({ title: t('toast_too_large_title'), description: sizeMb ? t('toast_too_large_with_size', { size: sizeMb }) : t('toast_too_large_generic'), variant: 'destructive', }) return } const body = await res.json().catch(() => ({})) throw new Error(body.error || t('error_create_archive')) } const blob = await res.blob() const contentDisposition = res.headers.get('Content-Disposition') || '' const match = contentDisposition.match(/filename="?([^";]+)"?/) const filename = match?.[1] || 'arkiv.zip' const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(url) const now = new Date().toISOString() if (storageKey) { window.localStorage.setItem(storageKey, now) setLastDownloadedAt(now) } toast({ title: t('toast_backup_created'), description: filename }) } catch (err) { toast({ title: t('toast_backup_failed'), description: err instanceof Error ? getUserErrorMessage(err) : t('toast_try_again'), variant: 'destructive', }) } finally { setIsDownloading(false) } }, [downloadUrl, scope, selectedPeriodId, storageKey, toast, t]) const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments const canDownload = !isDownloading && !isOverLimit && (scope === 'all' || !!selectedPeriodId) return (
{t('create_backup_title')}
setScope('all')} label={t('scope_all_label')} description={t('scope_all_desc')} recommendedLabel={t('recommended')} recommended /> setScope('period')} label={t('scope_period_label')} description={t('scope_period_desc')} recommendedLabel={t('recommended')} />
{/* Live region always mounted so the failure is announced when it appears, not merely inserted. The full-history scope stays fully functional; the line only reports that per-period backup cannot be offered until the fiscal years can be read. */}
{periodsError && ( setPeriodsReloadKey((k) => k + 1), } } > {periodsError.detail ? `${t('periods_load_failed')} ${periodsError.detail}` : t('periods_load_failed')} )}
{scope === 'period' && (
)}

{t('include_docs_help')}

{isLoadingEstimate ? ( {t('calculating_size')} ) : estimate ? ( {t('estimated_size')} {formatBytes(estimate.total_bytes)} {' '}({estimate.document_count} {estimate.document_count === 1 ? t('attachment_singular') : t('attachment_plural')}) ) : ( {t('size_will_calculate')} )}
{isOverLimit && (

{t('over_limit_message', { limit: formatBytes(estimate!.size_limit_bytes) })}

)}
{lastDownloadedAt && (

{t('last_download')}: {formatDate(lastDownloadedAt)}

)}
{hasCloudBackup && CloudBackupPanel ? ( ) : ( {t('cloud_sync_title')}

{t('cloud_sync_disabled_help')}

)}
) } interface ScopeRadioProps { checked: boolean onChange: () => void label: string description: string recommended?: boolean recommendedLabel: string } function ScopeRadio({ checked, onChange, label, description, recommended, recommendedLabel }: ScopeRadioProps) { return ( ) } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` const kb = bytes / 1024 if (kb < 1024) return `${kb.toFixed(1)} kB` const mb = kb / 1024 if (mb < 1024) return `${mb.toFixed(1)} MB` return `${(mb / 1024).toFixed(2)} GB` } function formatDate(iso: string): string { const d = new Date(iso) return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) }