'use client'
import { useState, useCallback, useEffect } from 'react'
import { fetchAccounts } from '@/lib/reference-data/fetchers'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { useSearchParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle, ShoppingBag, ShoppingCart } from 'lucide-react'
import { cn, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types'
// Register import (customers/suppliers) components
import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep'
import type { RegisterResult } from '@/components/import/RegisterResultStep'
import type {
CustomerImportParseResult,
AnnotatedCustomerRow,
DetectedCustomerColumns,
} from '@/lib/import/customers/types'
import type {
SupplierImportParseResult,
AnnotatedSupplierRow,
DetectedSupplierColumns,
} from '@/lib/import/suppliers/types'
import type {
ArticleImportParseResult,
AnnotatedArticleRow,
DetectedArticleColumns,
} from '@/lib/import/articles/types'
import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import { decodeFileContent } from '@/lib/import/shared/encoding'
import type { BankFileParseResult, BankFileFormatId, BankFileDuplicateInfo, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { SkattekontoFileParseResult } from '@/lib/import/skattekonto-file/types'
import type { SkattekontoFileImportResult } from '@/components/import/SkattekontoFileResultStep'
import type { IngestResult } from '@/types'
import type {
ImportWizardStep,
ParsedSIEFile,
AccountMapping,
ImportPreview,
ImportResult,
ParseIssue,
} from '@/lib/import/types'
import {
applyVatTreatmentReview,
applyVatTreatmentReviewAll,
enrichChangedAccountMappingWithVat,
enrichAccountMappingsWithVat,
} from '@/lib/import/account-vat-treatment'
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
import type { TheaterModel } from '@/lib/import/theater-model'
/** Above this size the client-side theater parse is skipped (main-thread
* parse of very large SIE files would jank the animation it exists for). */
const THEATER_MAX_FILE_BYTES = 8 * 1024 * 1024
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'
const MigrationWizard = dynamic(
() => import('@/components/extensions/general/ArcimMigrationWorkspace'),
{ ssr: false, loading: () =>
Laddar migreringsverktyg...
}
)
function ImportStepLoading() {
return (
)
}
const BankFileUploadStep = dynamic(() => import('@/components/import/BankFileUploadStep'), { loading: ImportStepLoading })
const BankFilePreviewStep = dynamic(() => import('@/components/import/BankFilePreviewStep'), { loading: ImportStepLoading })
const BankFileColumnMappingStep = dynamic(() => import('@/components/import/BankFileColumnMappingStep'), { loading: ImportStepLoading })
const BankFileConfirmStep = dynamic(() => import('@/components/import/BankFileConfirmStep'), { loading: ImportStepLoading })
const BankFileResultStep = dynamic(() => import('@/components/import/BankFileResultStep'), { loading: ImportStepLoading })
const OpeningBalanceUploadStep = dynamic(() => import('@/components/import/OpeningBalanceUploadStep'), { loading: ImportStepLoading })
const OpeningBalanceColumnMappingStep = dynamic(() => import('@/components/import/OpeningBalanceColumnMappingStep'), { loading: ImportStepLoading })
const OpeningBalanceEditStep = dynamic(() => import('@/components/import/OpeningBalanceEditStep'), { loading: ImportStepLoading })
const OpeningBalancePeriodStep = dynamic(() => import('@/components/import/OpeningBalancePeriodStep'), { loading: ImportStepLoading })
const OpeningBalanceResultStep = dynamic(() => import('@/components/import/OpeningBalanceResultStep'), { loading: ImportStepLoading })
const RegisterUploadStep = dynamic(() => import('@/components/import/RegisterUploadStep'), { loading: ImportStepLoading })
const CustomersEditStep = dynamic(() => import('@/components/import/CustomersEditStep'), { loading: ImportStepLoading })
const SuppliersEditStep = dynamic(() => import('@/components/import/SuppliersEditStep'), { loading: ImportStepLoading })
const ArticlesEditStep = dynamic(() => import('@/components/import/ArticlesEditStep'), { loading: ImportStepLoading })
const RegisterResultStep = dynamic(() => import('@/components/import/RegisterResultStep'), { loading: ImportStepLoading })
const SkattekontoFileUploadStep = dynamic(() => import('@/components/import/SkattekontoFileUploadStep'), { loading: ImportStepLoading })
const SkattekontoFilePreviewStep = dynamic(() => import('@/components/import/SkattekontoFilePreviewStep'), { loading: ImportStepLoading })
const SkattekontoFileResultStep = dynamic(() => import('@/components/import/SkattekontoFileResultStep'), { loading: ImportStepLoading })
const SIEUploadStep = dynamic(() => import('@/components/import/SIEUploadStep'), { loading: ImportStepLoading })
const SIEPreviewStep = dynamic(() => import('@/components/import/SIEPreviewStep'), { loading: ImportStepLoading })
const AccountMappingStep = dynamic(() => import('@/components/import/AccountMappingStep'), { loading: ImportStepLoading })
const ImportReviewStep = dynamic(() => import('@/components/import/ImportReviewStep'), { loading: ImportStepLoading })
const ImportResultStep = dynamic(() => import('@/components/import/ImportResultStep'), { loading: ImportStepLoading })
const SIEImportHistory = dynamic(() => import('@/components/import/SIEImportHistory'), { loading: ImportStepLoading })
const BankFileImportHistory = dynamic(() => import('@/components/import/BankFileImportHistory'), { loading: ImportStepLoading })
const UnderlagImportWizard = dynamic(() => import('@/components/import/UnderlagImportWizard'), { loading: ImportStepLoading })
// ============================================================
// Bank File Import Wizard Steps
// ============================================================
type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result'
const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result']
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'column_mapping', 'confirm', 'result']
const BANK_STEP_LABELS: Record = {
upload: 'Ladda upp',
preview: 'Förhandsgranskning',
column_mapping: 'Kolumnmappning',
confirm: 'Bekräfta',
result: 'Resultat',
}
function BankFileImportWizard() {
const { toast } = useToast()
const tTx = useTranslations('transactions')
const { company } = useCompany()
const [bankStep, setBankStep] = useState('upload')
const [bankIsLoading, setBankIsLoading] = useState(false)
const [bankError, setBankError] = useState(null)
const [bankErrorTitle, setBankErrorTitle] = useState(null)
// The uploaded file was recognized as a skattekontoutdrag: the upload step
// renders a pointer to the dedicated importer instead of a parse error.
const [skattekontoDetected, setSkattekontoDetected] = useState(false)
// Parse results
const [parseResult, setParseResult] = useState(null)
const [detectedFormat, setDetectedFormat] = useState(null)
const [detectedFormatName, setDetectedFormatName] = useState(null)
const [fileHash, setFileHash] = useState('')
const [filename, setFilename] = useState('')
const [rawFileContent, setRawFileContent] = useState('')
// Import result
const [ingestResult, setIngestResult] = useState(null)
// Advisory duplicate preview: which parsed rows ingest will skip because
// they already exist. Fetched from check-duplicates after every (re-)parse;
// a failed check leaves this null and the wizard proceeds without warning
// (execute-side dedup still skips, and the result step reports the count).
const [duplicateInfo, setDuplicateInfo] = useState(null)
const checkDuplicates = useCallback(async (transactions: BankFileParseResult['transactions'], format: BankFileFormatId) => {
setDuplicateInfo(null)
if (transactions.length === 0) return
try {
const res = await fetch('/api/import/bank-file/check-duplicates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactions, format }),
})
if (!res.ok) return
const data = await res.json()
const preview = data?.data
if (preview && Array.isArray(preview.duplicate_row_indexes)) {
setDuplicateInfo({
duplicate_row_indexes: preview.duplicate_row_indexes,
duplicate_count: typeof preview.duplicate_count === 'number'
? preview.duplicate_count
: preview.duplicate_row_indexes.length,
})
}
} catch {
// Advisory only: never block the wizard on a failed preview.
}
}, [])
// Active PSD2 connections: drives an overlap warning so users don't
// accidentally upload a CSV covering periods we already sync nightly.
const [activePsd2Banks, setActivePsd2Banks] = useState([])
useEffect(() => {
if (!company?.id) return
let cancelled = false
const supabase = createClient()
supabase
.from('bank_connections')
.select('bank_name')
.eq('company_id', company.id)
.eq('status', 'active')
.then(({ data }) => {
if (cancelled) return
const names = Array.from(new Set((data ?? []).map((r) => r.bank_name).filter(Boolean)))
setActivePsd2Banks(names)
})
return () => {
cancelled = true
}
}, [company?.id])
const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS
const currentStepIndex = steps.indexOf(bankStep)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => {
setBankError(null)
setBankErrorTitle(null)
setSkattekontoDetected(false)
setBankIsLoading(true)
try {
const formData = new FormData()
formData.append('file', file)
if (formatOverride) {
formData.append('format', formatOverride)
}
const res = await fetch('/api/import/bank-file/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
// Structured error envelope: { error: { code, message, message_en, details } }
const err = data?.error
if (err && typeof err === 'object') {
if (err.code === 'BANK_FILE_DUPLICATE') {
const importedAt = err.details?.importedAt ? formatDate(err.details.importedAt) : null
const count = typeof err.details?.importedCount === 'number' ? err.details.importedCount : null
const when = importedAt
? ` ${importedAt}${count !== null ? ` (${count} transaktioner)` : ''}`
: ''
setBankErrorTitle('Filen är redan importerad')
setBankError(
`Den här filen är redan importerad${when}. Transaktionerna finns redan under Transaktioner. ` +
'Exportera en ny fil från banken om du vill lägga till fler transaktioner.'
)
} else if (err.code === 'BANK_FILE_SKATTEKONTO_DETECTED') {
setSkattekontoDetected(true)
} else {
setBankError(getErrorMessage(err) || 'Kunde inte läsa filen')
}
} else {
setBankError(typeof err === 'string' ? err : 'Kunde inte läsa filen')
}
return
}
setParseResult(data.data.parse_result)
setDetectedFormat(data.data.detected_format)
setDetectedFormatName(data.data.detected_format_name)
setFileHash(data.data.file_hash)
setFilename(data.data.filename)
// Read raw file content for CSV preview. Decode from bytes, not
// file.text(): that is UTF-8-only and turns Windows-1252 åäö into
// U+FFFD (Handelsbanken exports Windows-1252), corrupting both the
// column-mapping preview and the re-parse on confirm.
const text = decodeFileContent(await file.arrayBuffer())
setRawFileContent(text)
const txCount = data.data.parse_result.transactions.length
if (data.data.parse_result.format === 'generic_csv') {
// Auto-detect failed or user picked "Annan CSV": always route to manual column mapping.
// Default mapping rarely matches, so advance regardless of tx count.
// (The duplicate check runs after column mapping instead, on the
// client-side re-parse.)
setDuplicateInfo(null)
setBankStep('column_mapping')
} else if (txCount > 0) {
// Fire-and-forget: the warning card/badges appear when the answer
// lands; the wizard never waits for it.
void checkDuplicates(data.data.parse_result.transactions, data.data.parse_result.format)
setBankStep('preview')
toast({
title: 'Fil analyserad',
description: `${txCount} transaktioner hittades`,
})
} else {
// Format detected but no transactions parsed: surface the parser's
// real issues when it produced any, so the user sees WHY (wrong
// columns, invalid dates, ...) instead of only a generic hint.
const parseIssues: BankFileParseResult['issues'] = data.data.parse_result.issues ?? []
if (parseIssues.length > 0) {
setBankError(
parseIssues
.slice(0, 5)
.map((issue) => (issue.row > 0 ? `Rad ${issue.row}: ${issue.message}` : issue.message))
.join(' ')
)
} else {
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
}
}
} catch (err) {
setBankError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setBankIsLoading(false)
}
}, [toast, checkDuplicates])
const handleColumnMappingConfirm = useCallback(async (mapping: GenericCSVColumnMapping) => {
// Re-parse with mapping via the generic CSV parser
const { parseGenericCSV } = await import('@/lib/import/bank-file/formats/generic-csv')
const result = parseGenericCSV(rawFileContent, mapping)
setParseResult(result)
// The generic path never re-hits the parse route, so this is its only
// duplicate check. Fire-and-forget: the confirm step renders immediately
// and the warning card appears when the answer lands.
void checkDuplicates(result.transactions, 'generic_csv')
setBankStep('confirm')
}, [rawFileContent, checkDuplicates])
const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => {
if (!parseResult) return
setBankIsLoading(true)
setBankError(null)
try {
const res = await fetch('/api/import/bank-file/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transactions: parseResult.transactions,
format: parseResult.format,
filename,
file_hash: fileHash,
...options,
}),
})
const data = await res.json()
if (!res.ok) {
// Map the parsed body plus the status: on the thrown-error path
// `data.error` is the canonical envelope OBJECT (withRouteContext),
// which must never be stored into a rendered string state.
setBankError(getErrorMessage(data, { statusCode: res.status }))
return
}
setIngestResult(data.data)
setBankStep('result')
toast({
title: 'Import genomförd',
description: `${data.data.imported} transaktioner importerades`,
})
} catch (err) {
setBankError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setBankIsLoading(false)
}
}, [parseResult, filename, fileHash, toast])
const handleNewImport = () => {
setBankStep('upload')
setParseResult(null)
setDetectedFormat(null)
setDetectedFormatName(null)
setFileHash('')
setFilename('')
setIngestResult(null)
setBankError(null)
setBankErrorTitle(null)
setSkattekontoDetected(false)
setRawFileContent('')
setDuplicateInfo(null)
}
return (
{/* Status chip for at-a-glance "auto-sync is healthy / stale / needs attention" */}
{/* Overlap warning: active PSD2 means file import will likely create
duplicates of transactions the nightly sync already covers. */}
{activePsd2Banks.length > 0 && (
)
}
// ============================================================
// Banking (PSD2) connect UI
// ============================================================
// Provided by the enable-banking extension and loaded through the settings
// panel registry (dynamic import), so this core page never imports from
// @/extensions directly. The shared panel renders every connection state
// (pending account selection, active, expiring, and expired/error with the
// reconnect entry point), which the old inline wizard here did not.
const BankingPanel = getSettingsPanel('enable-banking')
// Same registry mechanism for the Stripe connect/sync surface: the feed of
// payments, fees and payouts is an import source in the same category as the
// PSD2 bank connection above.
const StripePanel = getSettingsPanel('stripe')
// And for the WooCommerce order feed: the store's paid orders and refunds are
// an import source in the same category as the Stripe feed above.
const WooCommercePanel = getSettingsPanel('woocommerce')
// And for the Shopify order feed: same category as the WooCommerce feed above.
const ShopifyPanel = getSettingsPanel('shopify')
// ============================================================
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'skattekonto' | 'sie' | 'underlag' | 'csv_data' | 'migration'
export default function ImportPage() {
const { isSandbox, role } = useCompany()
const [mode, setMode] = useState(null)
const [initialProvider, setInitialProvider] = useState(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 [bankFileHistoryOpen, setBankFileHistoryOpen] = useState(false)
const [userId, setUserId] = useState('')
const [exportPeriodId, setExportPeriodId] = useState(null)
const [exportExcludeClosing, setExportExcludeClosing] = useState(true)
const t = useTranslations('import')
const router = useRouter()
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
const hasBankSync = useCapability(CAPABILITY.bank_sync)
// Fetch authenticated user ID (used by the migration wizard)
useEffect(() => {
const supabase = createClient()
supabase.auth.getUser().then(({ data: { user } }) => {
if (user) setUserId(user.id)
})
}, [])
// Sync mode + view from URL search params (reacts to client-side navigation changes)
const searchParams = useSearchParams()
useEffect(() => {
// External imports (provider migration, PSD2 bank connection) need live
// third-party credentials, so their deep links are ignored in the sandbox.
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
const allowedModes = isSandbox
? ['bank', 'skattekonto', 'sie', 'underlag', 'csv_data']
: ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'skattekonto', 'sie', 'underlag', 'csv_data', 'migration']
if (!isSandbox && searchParams.get('migration')) {
setMode('migration')
} else {
const modeParam = searchParams.get('mode')
if (modeParam && allowedModes.includes(modeParam)) {
setMode(modeParam as ImportMode)
}
// Deep link from the onboarding branch question: preselect the old
// system so the wizard can jump straight to its connect step. Cleared
// for every other mode so a stale preselect can't survive re-entry.
setInitialProvider(
modeParam === 'migration' && !isSandbox ? searchParams.get('provider') : null
)
}
const viewParam = searchParams.get('view')
if (viewParam === 'export' || viewParam === 'import') {
setView(viewParam)
}
// Deep link from surfaces where a bad import is discovered (e.g. the
// voucher list): /import?history=sie lands directly on the fold-open
// SIE import history so "Ångra import" is one click away.
if (searchParams.get('history') === 'sie') {
setView('import')
setSieHistoryOpen(true)
// The history table renders below ~8 import rows: scroll it into view
// (same pattern as the #cloud-backup hash deep link below).
setTimeout(() => {
document
.getElementById('sie-import-history')
?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, 80)
}
}, [isSandbox, searchParams])
// 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)
setTimeout(() => {
document.querySelector(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, 80)
}
}, [])
const handleViewChange = (next: 'import' | 'export') => {
setView(next)
const params = new URLSearchParams(searchParams.toString())
if (next === 'export') params.set('view', 'export')
else params.delete('view')
const qs = params.toString()
router.replace(qs ? `/import?${qs}` : '/import', { scroll: false })
}
// Extensions are active if compiled in: no runtime toggle check needed
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
// Stripe is enabled everywhere (hosted + self-hosted); only the sandbox blocks it.
const stripeDisabled = isSandbox
const hasWooCommerceExtension = ENABLED_EXTENSION_IDS.has('woocommerce')
// Same doctrine as Stripe: external credentials never leave the sandbox.
const woocommerceDisabled = isSandbox
const hasShopifyExtension = ENABLED_EXTENSION_IDS.has('shopify')
const shopifyDisabled = isSandbox
return (
{t('help_text')}
}
/>
{mode === null && (
<>
{isSandbox && {t('sandbox_disabled')}}
{/* Importera / Exportera as separate tabs (house seg), like before */}
{view === 'import' ? (
{hasBankingExtension && (
{t('psd2_recommended')}
) : (
{t('psd2_requires_subscription')}
)
}
chips={}
disabled={isSandbox}
onClick={() => setMode('psd2')}
/>
)}
{hasStripeExtension && (
}
disabled={stripeDisabled}
onClick={() => setMode('stripe')}
/>
)}
{hasWooCommerceExtension && (
}
chips={}
disabled={woocommerceDisabled}
onClick={() => setMode('woocommerce')}
/>
)}
{hasShopifyExtension && (
}
chips={}
disabled={shopifyDisabled}
onClick={() => setMode('shopify')}
/>
)}
{hasMigrationExtension && (
>
}
disabled={isSandbox}
onClick={() => setMode('migration')}
/>
)}
setMode('bank')}
/>
setMode('skattekonto')}
/>
setMode('csv_data')}
/>
setMode('sie')}
/>
{/* Optional follow-up to a SIE import, never a step inside it:
the receipts usually arrive later and from another export. */}
setMode('underlag')}
/>
setSieHistoryOpen((v) => !v)}
/>
setBankFileHistoryOpen((v) => !v)}
/>
{sieHistoryOpen && (
)}
{bankFileHistoryOpen && (
)}
{t('pgnote')}
) : (
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') && (
setArchiveDialogOpen(true)}
/>
)}
{hasCloudBackup && (
setCloudOpen((v) => !v)}
/>
)}
{hasCloudBackup && cloudOpen && (
)}
)}
{/* Full archive (SIE + reports + all documents) as the same kind of
small centered dialog as the SIE export below. */}
{/* SIE export as a small centered dialog (concept overlay convention) */}
>
)}
{mode !== null && (
)}
{mode === 'psd2' && (
hasBankingExtension && BankingPanel ? (
) : (
Bankintegration (PSD2) är inte aktiverad
Aktivera tillägget Enable Banking för att koppla ditt bankkonto, eller importera
transaktioner manuellt via bankfil.
)
}
// Quiet action row (concept scene 32): borderless list row with title, muted
// sub-line and a chevron; chips only for the recommended/gated exceptions.
function ImportRow({
title,
sub,
chip,
chips,
disabled = false,
expanded,
onClick,
id,
}: {
title: string
sub: string
chip?: React.ReactNode
/** Logo chips under the sub line (provider marks, as on the live page). */
chips?: React.ReactNode
disabled?: boolean
/** For rows that fold a panel open below the grid (cloud backup). */
expanded?: boolean
onClick: () => void
id?: string
}) {
return (
)
}
// Same quiet beta-badge recipe as the sidebar nav (DashboardNav renderBadge),
// so "Beta" reads identically wherever it appears.
function BetaChip({ label }: { label: string }) {
return (
{label}
)
}
// Provider mark chip (same recipe as the pre-migration live page): tiny logo
// on a quiet bordered chip, so integrations read as first-class brands.
// `mono` is for light-on-transparent marks (Enable Banking): the marketing
// site's grayscale+brightness treatment makes them read on a light ground,
// with the inverse lift in dark mode.
function LogoChip({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) {
return (
{name}
)
}