'use client' import { useTranslations } from 'next-intl' import { useCallback, useEffect, useMemo, useState } from 'react' 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' 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 { toast } = useToast() const { company } = useCompany() const [scope, setScope] = useState('all') const [includeDocuments, setIncludeDocuments] = useState(true) const [periods, setPeriods] = useState([]) 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() { try { const res = await fetch('/api/bookkeeping/fiscal-periods') const { data } = await res.json() if (cancelled) return const sorted = (data || []) as FiscalPeriod[] setPeriods(sorted) if (sorted.length > 0 && !selectedPeriodId) { setSelectedPeriodId(sorted[0].id) } } catch { // silent: scope=all still works without periods loaded } } loadPeriods() return () => { cancelled = true } // Intentionally run once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []) 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 ? err.message : 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')} />
{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', }) }