feat(export): direct download of the complete archive from the Exportera tab (#1632)
The full-archive ZIP endpoint (SIE + reports + all documents) has existed since the settings/backup page, but lost its UI when that page became a redirect: the BackupDownloadForm component was orphaned and the download was API-only. Resurface it the way the export tab already works: a "Komplett arkiv" ImportRow (owner/admin only, matching the route's role gate) opening a small centered dialog like the SIE export next to it, with scope choice, fiscal-year picker, include-documents toggle, live size estimate, 413 handling, and a #full-archive deep link. The orphaned form and its dead settings_backup_download i18n namespace are deleted; its logic lives on in components/import/FullArchiveDialog. Over-limit copy now points at the existing cloud sync instead of promising it "in a later version". Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1022,3 +1022,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-16] /transactions FyPicker double-fetch fixed by gating the initial fetch on FyPicker's existing onReady (fires after its restore onChange) instead of the analysis doc's literal "read the persisted period synchronously in initial state": localStorage only holds the period ID, not the FiscalPeriod bounds, so a synchronous read would suppress FyPicker's restore (value !== null) and leave the fetch permanently unscoped while the chip claimed a year. Same outcome (one scoped fetch per mount, background refetch on period change) without a stale-bounds cache or new FyPicker API.
|
||||
[2026-08-16] Row exit animation for dry-table <tr> rows collapses via td padding/line-height/font-size transitions plus a numeric max-height (.row-collapsible) on the fixed-height cell spans, not grid-template-rows 0fr (the AttGoraSection pattern): table cells cannot host the grid wrapper without restructuring every td, and max-height needs a numeric rest value because auto/none does not interpolate. prefers-reduced-motion hides the exiting row instantly (display: none) while the 350ms timer does the state cleanup.
|
||||
[2026-08-16] Restyled QuickReviewDialog's inbox-picker trigger to the same full-width dropzone-footer row as TransactionBookingDialog even though it did not share the orphan-button layout: both surfaces come from #1620 and should present the same underlag affordance; the alternative (leaving a small outline button in one dialog and a footer row in the other) would split the visual language of one control. Presentation only, disabled-while-booking kept (PR #1628).
|
||||
[2026-08-17] Full-archive direct download resurfaced as an ImportRow + small centered dialog on /import's Exportera tab (row "Komplett arkiv", hash #full-archive), not by re-mounting the orphaned components/settings/BackupDownloadForm.tsx: the form was pre-frame card styling with a duplicate cloud-backup section, while the export tab's existing SIE dialog sets the house pattern (ImportRow -> sm:max-w-md dialog). Its logic (estimate, 413 handling, last-download stamp) ported into components/import/FullArchiveDialog.tsx; the orphan and its dead settings_backup_download i18n namespace deleted. The dialog reuses FiscalYearSelector despite design.md's "legacy, no new uses" line: FyPicker is a toolbar context chip, and the SIE dialog in the same file already uses FiscalYearSelector for the identical dialog-form slot, so matching it beats introducing a third pattern. Row gated to owner/admin because GET /api/reports/full-archive enforces that role server-side; showing members a download that can only 403 helps nobody.
|
||||
|
||||
@@ -78,6 +78,7 @@ import type { BASAccount } from '@/types'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import { FullArchiveDialog } from '@/components/import/FullArchiveDialog'
|
||||
import CloudBackupCard from '@/extensions/general/cloud-backup/components/CloudBackupCard'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
|
||||
@@ -2111,11 +2112,12 @@ const ShopifyPanel = getSettingsPanel('shopify')
|
||||
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'sie' | 'underlag' | 'csv_data' | 'migration'
|
||||
|
||||
export default function ImportPage() {
|
||||
const { isSandbox } = useCompany()
|
||||
const { isSandbox, role } = useCompany()
|
||||
const [mode, setMode] = useState<ImportMode>(null)
|
||||
const [initialProvider, setInitialProvider] = useState<string | null>(null)
|
||||
const [view, setView] = useState<'import' | 'export'>('import')
|
||||
const [sieDialogOpen, setSieDialogOpen] = useState(false)
|
||||
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false)
|
||||
const [cloudOpen, setCloudOpen] = useState(false)
|
||||
const [sieHistoryOpen, setSieHistoryOpen] = useState(false)
|
||||
const [userId, setUserId] = useState('')
|
||||
@@ -2163,14 +2165,18 @@ export default function ImportPage() {
|
||||
}
|
||||
}, [isSandbox, searchParams])
|
||||
|
||||
// Hash-based deep links: both live on the export tab; #sie-export opens
|
||||
// the SIE dialog, #cloud-backup expands the cloud panel and scrolls to it.
|
||||
// Hash-based deep links: all live on the export tab; #sie-export opens
|
||||
// the SIE dialog, #full-archive opens the archive download dialog, and
|
||||
// #cloud-backup expands the cloud panel and scrolls to it.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const hash = window.location.hash
|
||||
if (hash === '#sie-export') {
|
||||
setView('export')
|
||||
setSieDialogOpen(true)
|
||||
} else if (hash === '#full-archive') {
|
||||
setView('export')
|
||||
setArchiveDialogOpen(true)
|
||||
} else if (hash === '#cloud-backup') {
|
||||
setView('export')
|
||||
setCloudOpen(true)
|
||||
@@ -2336,6 +2342,17 @@ export default function ImportPage() {
|
||||
sub={t('export_sie_description')}
|
||||
onClick={() => setSieDialogOpen(true)}
|
||||
/>
|
||||
{/* The full-archive route is owner/admin-only (double-checked
|
||||
server-side), so the row hides for members and viewers
|
||||
instead of offering a download that can only 403. */}
|
||||
{(role === 'owner' || role === 'admin') && (
|
||||
<ImportRow
|
||||
id="full-archive"
|
||||
title={t('export_archive_title')}
|
||||
sub={t('export_archive_description')}
|
||||
onClick={() => setArchiveDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{hasCloudBackup && (
|
||||
<ImportRow
|
||||
title={t('cloud_row_title')}
|
||||
@@ -2353,6 +2370,10 @@ export default function ImportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full archive (SIE + reports + all documents) as the same kind of
|
||||
small centered dialog as the SIE export below. */}
|
||||
<FullArchiveDialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen} />
|
||||
|
||||
{/* SIE export as a small centered dialog (concept overlay convention) */}
|
||||
<Dialog open={sieDialogOpen} onOpenChange={setSieDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
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 { 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
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export function FullArchiveDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const t = useTranslations('import')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const { formatDateLong } = useFormat()
|
||||
|
||||
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 [isLoadingEstimate, setIsLoadingEstimate] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [lastDownloadedAt, setLastDownloadedAt] = useState<string | null>(null)
|
||||
|
||||
const storageKey = useMemo(
|
||||
() => (company ? `${LAST_DOWNLOAD_STORAGE_KEY}:${company.id}` : null),
|
||||
[company]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey || !open) return
|
||||
setLastDownloadedAt(window.localStorage.getItem(storageKey))
|
||||
}, [storageKey, open])
|
||||
|
||||
const archiveUrl = useMemo(() => {
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || (scope === 'period' && !periodId)) {
|
||||
setEstimate(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setIsLoadingEstimate(true)
|
||||
setEstimate(null)
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${archiveUrl}&estimate=1`)
|
||||
if (!res.ok) return
|
||||
const { data } = (await res.json()) as { data: EstimateResponse }
|
||||
if (!cancelled) setEstimate(data)
|
||||
} catch {
|
||||
// leave estimate null; the user can still attempt the download
|
||||
} finally {
|
||||
if (!cancelled) setIsLoadingEstimate(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, archiveUrl, scope, periodId])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (scope === 'period' && !periodId) return
|
||||
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const res = await fetch(archiveUrl)
|
||||
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('archive_toast_too_large_title'),
|
||||
description: 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'))
|
||||
}
|
||||
|
||||
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('archive_toast_created'), description: filename })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('archive_toast_failed'),
|
||||
description:
|
||||
err instanceof Error
|
||||
? getErrorMessage(err, { locale: errorLocale })
|
||||
: t('archive_error_fallback'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}, [archiveUrl, scope, periodId, storageKey, toast, t, errorLocale])
|
||||
|
||||
const isOverLimit = !!estimate && !estimate.within_limit && includeDocuments
|
||||
const canDownload = !isDownloading && !isOverLimit && (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')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[13px] leading-relaxed">
|
||||
{t('archive_dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<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') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{scope === 'period' && (
|
||||
<FiscalYearSelector
|
||||
value={periodId}
|
||||
onChange={setPeriodId}
|
||||
includeAllOption={false}
|
||||
hideFuturePeriods
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="archive-include-documents">{t('archive_include_docs_label')}</Label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{t('archive_include_docs_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="archive-include-documents"
|
||||
checked={includeDocuments}
|
||||
onCheckedChange={setIncludeDocuments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div role="status" aria-live="polite" className="space-y-1">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{isLoadingEstimate ? (
|
||||
t('archive_calculating_size')
|
||||
) : estimate ? (
|
||||
<>
|
||||
{t('archive_estimated_size')}{' '}
|
||||
<strong className="font-medium tabular-nums text-foreground">
|
||||
{formatBytes(estimate.total_bytes)}
|
||||
</strong>{' '}
|
||||
({estimate.document_count}{' '}
|
||||
{estimate.document_count === 1
|
||||
? t('archive_attachment_singular')
|
||||
: t('archive_attachment_plural')}
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
t('archive_size_pending')
|
||||
)}
|
||||
</p>
|
||||
{lastDownloadedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('archive_last_download')}: {formatDateLong(lastDownloadedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOverLimit && (
|
||||
<AttnLine>
|
||||
{t('archive_over_limit', { limit: formatBytes(estimate!.size_limit_bytes) })}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleDownload} disabled={!canDownload}>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('archive_creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('archive_download_button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
'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<Scope>('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<FiscalPeriod[] | null>(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<string>('')
|
||||
const [estimate, setEstimate] = useState<EstimateResponse | null>(null)
|
||||
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [lastDownloadedAt, setLastDownloadedAt] = useState<string | null>(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 (
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('create_backup_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('scope_label')}</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<ScopeRadio
|
||||
checked={scope === 'all'}
|
||||
onChange={() => setScope('all')}
|
||||
label={t('scope_all_label')}
|
||||
description={t('scope_all_desc')}
|
||||
recommendedLabel={t('recommended')}
|
||||
recommended
|
||||
/>
|
||||
<ScopeRadio
|
||||
checked={scope === 'period'}
|
||||
onChange={() => setScope('period')}
|
||||
label={t('scope_period_label')}
|
||||
description={t('scope_period_desc')}
|
||||
recommendedLabel={t('recommended')}
|
||||
/>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div role="status" aria-live="polite" className="min-w-0">
|
||||
{periodsError && (
|
||||
<AttnLine
|
||||
action={
|
||||
periodsError.detail
|
||||
? undefined
|
||||
: {
|
||||
label: t('periods_load_retry'),
|
||||
onClick: () => setPeriodsReloadKey((k) => k + 1),
|
||||
}
|
||||
}
|
||||
>
|
||||
{periodsError.detail
|
||||
? `${t('periods_load_failed')} ${periodsError.detail}`
|
||||
: t('periods_load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope === 'period' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-period">{t('fiscal_year_label')}</Label>
|
||||
<select
|
||||
id="backup-period"
|
||||
value={selectedPeriodId}
|
||||
onChange={(e) => setSelectedPeriodId(e.target.value)}
|
||||
className="flex h-10 w-full max-w-xs rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
disabled={periods === null || periods.length === 0}
|
||||
>
|
||||
{/* The confirmed-empty option only after a confirmed empty
|
||||
read; an unknown list renders an empty disabled select. */}
|
||||
{periods !== null && periods.length === 0 && (
|
||||
<option value="">{t('no_fiscal_years')}</option>
|
||||
)}
|
||||
{(periods ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.period_start}: {p.period_end}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="include-documents">{t('include_docs_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-prose">
|
||||
{t('include_docs_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="include-documents"
|
||||
checked={includeDocuments}
|
||||
onCheckedChange={setIncludeDocuments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
{isLoadingEstimate ? (
|
||||
<span>{t('calculating_size')}</span>
|
||||
) : estimate ? (
|
||||
<span>
|
||||
{t('estimated_size')} <strong className="text-foreground">{formatBytes(estimate.total_bytes)}</strong>
|
||||
{' '}({estimate.document_count} {estimate.document_count === 1 ? t('attachment_singular') : t('attachment_plural')})
|
||||
</span>
|
||||
) : (
|
||||
<span>{t('size_will_calculate')}</span>
|
||||
)}
|
||||
</div>
|
||||
{isOverLimit && (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
{t('over_limit_message', { limit: formatBytes(estimate!.size_limit_bytes) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button onClick={handleDownload} disabled={!canDownload}>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('creating_backup')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('create_and_download')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{lastDownloadedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('last_download')}: {formatDate(lastDownloadedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasCloudBackup && CloudBackupPanel ? (
|
||||
<CloudBackupPanel />
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cloud className="h-4 w-4 text-muted-foreground" />
|
||||
{t('cloud_sync_title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground max-w-prose">
|
||||
{t('cloud_sync_disabled_help')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ScopeRadioProps {
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
label: string
|
||||
description: string
|
||||
recommended?: boolean
|
||||
recommendedLabel: string
|
||||
}
|
||||
|
||||
function ScopeRadio({ checked, onChange, label, description, recommended, recommendedLabel }: ScopeRadioProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`flex-1 rounded-lg border-2 p-3 text-left transition-colors ${
|
||||
checked ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
{recommended && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-primary">
|
||||
{recommendedLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
+23
-33
@@ -2409,39 +2409,6 @@
|
||||
"fyllnadsinbetalning_label": "Extra preliminary tax payment (fyllnadsinbetalning)",
|
||||
"fyllnadsinbetalning_help": "Extra preliminary tax payment to avoid interest on back taxes. Amounts over 30,000 kr by the 12th of the second month after the fiscal year, the rest by the 3rd of the fifth month."
|
||||
},
|
||||
"settings_backup_download": {
|
||||
"create_backup_title": "Create backup",
|
||||
"scope_label": "Scope",
|
||||
"scope_all_label": "Full history",
|
||||
"scope_all_desc": "All fiscal years and vouchers",
|
||||
"scope_period_label": "Single period",
|
||||
"scope_period_desc": "Pick a specific fiscal year",
|
||||
"recommended": "Recommended",
|
||||
"fiscal_year_label": "Fiscal year",
|
||||
"no_fiscal_years": "No fiscal years",
|
||||
"include_docs_label": "Include receipts and supporting documents",
|
||||
"include_docs_help": "Voucher attachments (receipts, invoices, PDFs) are packed in the ZIP. Turn off for a smaller backup with bookkeeping data only.",
|
||||
"calculating_size": "Calculating size…",
|
||||
"estimated_size": "Estimated size:",
|
||||
"attachment_singular": "attachment",
|
||||
"attachment_plural": "attachments",
|
||||
"size_will_calculate": "Size is calculated when scope is selected.",
|
||||
"over_limit_message": "The archive is larger than {limit} and cannot be downloaded directly. Pick a single period or disable attachments for now: automatic cloud sync is coming in a later version.",
|
||||
"creating_backup": "Creating backup…",
|
||||
"create_and_download": "Create and download",
|
||||
"last_download": "Last download",
|
||||
"toast_too_large_title": "Archive too large for direct download",
|
||||
"toast_too_large_with_size": "Your archive is about {size} MB. Export one period at a time for now: automatic cloud sync is coming in a later version.",
|
||||
"toast_too_large_generic": "Export one period at a time for now: automatic cloud sync is coming in a later version.",
|
||||
"error_create_archive": "Could not create the archive",
|
||||
"toast_backup_created": "Backup created",
|
||||
"toast_backup_failed": "Could not create backup",
|
||||
"toast_try_again": "Please try again.",
|
||||
"cloud_sync_title": "Cloud sync",
|
||||
"cloud_sync_disabled_help": "Enable the \"Cloud sync\" extension to connect Google Drive and upload your backup with one click.",
|
||||
"periods_load_failed": "The fiscal years could not be loaded, so a single period cannot be selected right now. The full history can still be downloaded.",
|
||||
"periods_load_retry": "Try again"
|
||||
},
|
||||
"settings_api_keys": {
|
||||
"title": "API keys",
|
||||
"description": "Manage keys for MCP clients (Claude, Cursor) and other integrations.",
|
||||
@@ -7081,6 +7048,29 @@
|
||||
"export_sie_dialog_description": "Download your bookkeeping as a SIE4 file for your accountant or for moving to another system.",
|
||||
"cloud_row_title": "Cloud sync",
|
||||
"cloud_row_description": "Continuous backup of the archive to Google Drive",
|
||||
"export_archive_title": "Complete archive",
|
||||
"export_archive_description": "Your entire bookkeeping as a ZIP: SIE, reports and all supporting documents",
|
||||
"archive_dialog_description": "Download a complete archive to your computer, with SIE files, reports and all supporting documents organized per voucher.",
|
||||
"archive_scope_label": "Scope",
|
||||
"archive_scope_all": "Full history",
|
||||
"archive_scope_period": "Single fiscal year",
|
||||
"archive_include_docs_label": "Include receipts and supporting documents",
|
||||
"archive_include_docs_help": "Voucher attachments (receipts, invoices, PDFs) are packed into the ZIP. Turn off for a smaller archive with bookkeeping data only.",
|
||||
"archive_calculating_size": "Calculating size…",
|
||||
"archive_estimated_size": "Estimated size:",
|
||||
"archive_attachment_singular": "attachment",
|
||||
"archive_attachment_plural": "attachments",
|
||||
"archive_size_pending": "Size is calculated when scope is selected.",
|
||||
"archive_over_limit": "The archive is larger than {limit} and cannot be downloaded directly. Pick a single fiscal year, turn off attachments, or use Cloud sync which handles larger archives.",
|
||||
"archive_download_button": "Create and download",
|
||||
"archive_creating": "Creating archive…",
|
||||
"archive_last_download": "Last download",
|
||||
"archive_toast_too_large_title": "Archive too large for direct download",
|
||||
"archive_toast_too_large_with_size": "Your archive is about {size} MB. Download one fiscal year at a time, or use Cloud sync which handles larger archives.",
|
||||
"archive_toast_too_large_generic": "Download one fiscal year at a time, or use Cloud sync which handles larger archives.",
|
||||
"archive_toast_created": "Archive downloaded",
|
||||
"archive_toast_failed": "Could not create the archive",
|
||||
"archive_error_fallback": "Please try again.",
|
||||
"help_text": "Every way in and out lives here: bank connections, file imports and migrations from other systems, plus SIE export and backups. Every import is reviewed before anything is booked.",
|
||||
"pgnote": "Every import goes through the same steps: upload, map columns, review, result. Nothing is booked without you seeing it first.",
|
||||
"sie_history_title": "Previous SIE imports",
|
||||
|
||||
+23
-33
@@ -2409,39 +2409,6 @@
|
||||
"fyllnadsinbetalning_label": "Fyllnadsinbetalning",
|
||||
"fyllnadsinbetalning_help": "Extra inbetalning av preliminärskatt för att undvika kostnadsränta på kvarskatt. Belopp över 30 000 kr senast den 12:e i andra månaden efter beskattningsåret, resten senast den 3:e i femte månaden."
|
||||
},
|
||||
"settings_backup_download": {
|
||||
"create_backup_title": "Skapa backup",
|
||||
"scope_label": "Omfattning",
|
||||
"scope_all_label": "Hela historiken",
|
||||
"scope_all_desc": "Alla räkenskapsår och verifikationer",
|
||||
"scope_period_label": "En period",
|
||||
"scope_period_desc": "Välj ett specifikt räkenskapsår",
|
||||
"recommended": "Rekommenderas",
|
||||
"fiscal_year_label": "Räkenskapsår",
|
||||
"no_fiscal_years": "Inga räkenskapsår",
|
||||
"include_docs_label": "Inkludera kvitton och underlag",
|
||||
"include_docs_help": "Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en. Stäng av för en mindre backup med bara bokföringsdata.",
|
||||
"calculating_size": "Beräknar storlek…",
|
||||
"estimated_size": "Uppskattad storlek:",
|
||||
"attachment_singular": "bilaga",
|
||||
"attachment_plural": "bilagor",
|
||||
"size_will_calculate": "Storlek beräknas när omfattning är vald.",
|
||||
"over_limit_message": "Arkivet är större än {limit} och kan inte laddas ner direkt. Välj en enskild period eller stäng av bilagor tills vidare: automatisk molnsynkronisering kommer i senare version.",
|
||||
"creating_backup": "Skapar backup…",
|
||||
"create_and_download": "Skapa och ladda ner",
|
||||
"last_download": "Senaste nedladdning",
|
||||
"toast_too_large_title": "Arkivet är för stort för direktnedladdning",
|
||||
"toast_too_large_with_size": "Ditt arkiv är cirka {size} MB. Exportera en period i taget tills vidare: automatisk molnsynkronisering kommer i senare version.",
|
||||
"toast_too_large_generic": "Exportera en period i taget tills vidare: automatisk molnsynkronisering kommer i senare version.",
|
||||
"error_create_archive": "Kunde inte skapa arkivet",
|
||||
"toast_backup_created": "Säkerhetsbackup skapad",
|
||||
"toast_backup_failed": "Kunde inte skapa säkerhetsbackup",
|
||||
"toast_try_again": "Försök igen.",
|
||||
"cloud_sync_title": "Molnsynkronisering",
|
||||
"cloud_sync_disabled_help": "Aktivera tillägget \"Molnsynkronisering\" för att koppla Google Drive och ladda upp säkerhetsbackupen med ett klick.",
|
||||
"periods_load_failed": "Räkenskapsåren kunde inte läsas in, så en enskild period kan inte väljas just nu. Hela historiken går fortfarande att ladda ner.",
|
||||
"periods_load_retry": "Försök igen"
|
||||
},
|
||||
"settings_api_keys": {
|
||||
"title": "API-nycklar",
|
||||
"description": "Hantera nycklar för MCP-klienter (Claude, Cursor) och andra integrationer.",
|
||||
@@ -7081,6 +7048,29 @@
|
||||
"export_sie_dialog_description": "Ladda ner bokföringen som SIE4-fil för revisorn eller för flytt till ett annat system.",
|
||||
"cloud_row_title": "Molnsynkronisering",
|
||||
"cloud_row_description": "Löpande säkerhetskopia av arkivet till Google Drive",
|
||||
"export_archive_title": "Komplett arkiv",
|
||||
"export_archive_description": "Hela bokföringen som ZIP: SIE, rapporter och alla underlag",
|
||||
"archive_dialog_description": "Ladda ner ett komplett arkiv till din dator, med SIE-filer, rapporter och alla underlag ordnade per verifikat.",
|
||||
"archive_scope_label": "Omfattning",
|
||||
"archive_scope_all": "Hela historiken",
|
||||
"archive_scope_period": "Ett räkenskapsår",
|
||||
"archive_include_docs_label": "Inkludera kvitton och underlag",
|
||||
"archive_include_docs_help": "Bilagor till verifikationer (kvitton, fakturor, PDF:er) packas med i ZIP:en. Stäng av för ett mindre arkiv med bara bokföringsdata.",
|
||||
"archive_calculating_size": "Beräknar storlek…",
|
||||
"archive_estimated_size": "Uppskattad storlek:",
|
||||
"archive_attachment_singular": "bilaga",
|
||||
"archive_attachment_plural": "bilagor",
|
||||
"archive_size_pending": "Storlek beräknas när omfattning är vald.",
|
||||
"archive_over_limit": "Arkivet är större än {limit} och kan inte laddas ner direkt. Välj ett enskilt räkenskapsår, stäng av bilagor eller använd Molnsynkronisering som klarar större arkiv.",
|
||||
"archive_download_button": "Skapa och ladda ner",
|
||||
"archive_creating": "Skapar arkiv…",
|
||||
"archive_last_download": "Senaste nedladdning",
|
||||
"archive_toast_too_large_title": "Arkivet är för stort för direktnedladdning",
|
||||
"archive_toast_too_large_with_size": "Ditt arkiv är cirka {size} MB. Ladda ner ett räkenskapsår i taget, eller använd Molnsynkronisering som klarar större arkiv.",
|
||||
"archive_toast_too_large_generic": "Ladda ner ett räkenskapsår i taget, eller använd Molnsynkronisering som klarar större arkiv.",
|
||||
"archive_toast_created": "Arkivet har laddats ner",
|
||||
"archive_toast_failed": "Kunde inte skapa arkivet",
|
||||
"archive_error_fallback": "Försök igen.",
|
||||
"help_text": "Här samlas alla vägar in och ut: bankkoppling, filimporter och flytt från andra system, samt export av SIE och säkerhetskopior. Varje import granskas innan något bokförs.",
|
||||
"pgnote": "Varje import går genom samma steg: ladda upp, mappa kolumner, granska, resultat. Inget bokförs utan att du ser det först.",
|
||||
"sie_history_title": "Tidigare SIE-importer",
|
||||
|
||||
Reference in New Issue
Block a user