Files
accounted/components/import/BankFileConfirmStep.tsx
T
Jakob Wennberg 3ee3565d6d perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941)
Final consumer migration of the responsiveness plan: the 35 files still
fetching fiscal periods, settings, accounts, cash accounts, dimensions or
templates on their own now read lib/reference-data, and every client
write site invalidates the shared cache instead of refetching locally.

Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period
snapshotted once per company so a revalidation cannot reset dates being
edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager,
EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog,
InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager,
DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id])
reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached
helpers are deleted.

Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per
company load), use-account-names, FiscalYearGapNotice,
OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import
page (invalidates accounts + periods after a SIE execute), customers list,
invoices list + detail, pending, salary employee, asset dispose, year-end
and periodisering pages (invalidate periods after closing), reports
DimensionPnlView (its pivot picker read the wrong payload key and was
always empty; it now populates), SkatteverketPanel, TemplatePicker,
ArticleForm (vat_registered).

Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog
(init reduced to the credit-note lookup + catalogue, proposal and voucher
preview fire on open when cached; a local getSession replaces the network
getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace,
ArcimMigrationWorkspace (invalidates after each SIE import step),
enable-banking AccountPickerDialog.

raw-reference-fetch ratchet: 35 -> 0 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:56:37 +02:00

270 lines
10 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useAccounts } from '@/lib/reference-data/hooks'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
ArrowLeft,
Loader2,
Play,
FileText,
Link2,
Calendar,
Landmark,
AlertTriangle,
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary'
import type { BankFileParseResult, BankFileDuplicateInfo } from '@/lib/import/bank-file/types'
interface BankAccount {
account_number: string
account_name: string
}
interface BankFileConfirmStepProps {
parseResult: BankFileParseResult
duplicateInfo?: BankFileDuplicateInfo | null
onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => void
onBack: () => void
isLoading: boolean
}
export default function BankFileConfirmStep({
parseResult,
duplicateInfo,
onExecute,
onBack,
isLoading,
}: BankFileConfirmStepProps) {
const t = useTranslations('transactions')
const { transactions, stats, date_from, date_to, issues } = parseResult
const refsCount = transactions.filter((tx) => tx.reference).length
const warnings = issues.filter((i) => i.severity === 'warning')
// Same per-currency grouping as the preview step: parser-level totals sum
// across currencies, which misleads on Wise/camt.053 multi-currency files.
const currencyTotals = summarizeByCurrency(transactions)
// Advisory: clamp so a stale preview can never produce a negative CTA
// count. Execute stays authoritative; the copy says rows are skipped
// automatically rather than promising an exact final number.
const duplicateCount = Math.min(Math.max(duplicateInfo?.duplicate_count ?? 0, 0), stats.parsed_rows)
const [selectedAccount, setSelectedAccount] = useState('1930')
// Active 19xx accounts from the session-cached chart (lib/reference-data):
// the account select is populated on the first paint.
const { accounts } = useAccounts()
const bankAccounts = useMemo<BankAccount[]>(
() =>
accounts
.filter((a) => a.account_number >= '1900' && a.account_number <= '1999')
.sort((a, b) => a.account_number.localeCompare(b.account_number))
.map((a) => ({ account_number: a.account_number, account_name: a.account_name })),
[accounts],
)
// Default to 1930 if available, otherwise the first account (once).
const defaultedRef = useRef(false)
useEffect(() => {
if (defaultedRef.current || bankAccounts.length === 0) return
defaultedRef.current = true
const has1930 = bankAccounts.some((a) => a.account_number === '1930')
if (!has1930) setSelectedAccount(bankAccounts[0].account_number)
}, [bankAccounts])
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 space-y-6">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
<div className="text-center space-y-2">
<p className="text-lg font-medium">Importerar transaktioner...</p>
<p className="text-sm text-muted-foreground">
{stats.parsed_rows} transaktioner bearbetas
</p>
</div>
<div className="w-48 h-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '60%' }} />
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Summary */}
<Card>
<CardHeader>
<CardTitle>Bekräfta import</CardTitle>
<CardDescription>
Granska sammanfattningen och importera transaktionerna.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-xs">Transaktioner</span>
</div>
<p className="text-xl font-display tabular-nums">{stats.parsed_rows}</p>
{stats.skipped_rows > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{stats.skipped_rows} rader hoppades över
</p>
)}
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-xs">Period</span>
</div>
<p className="text-sm font-medium">
{date_from}: {date_to}
</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Inkomster</span>
</div>
{(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => (
<p key={row.currency} className="text-xl font-display tabular-nums">
{formatCurrency(row.total_income, row.currency)}
</p>
))}
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Utgifter</span>
</div>
{(currencyTotals.length ? currencyTotals : [{ currency: 'SEK', total_income: 0, total_expenses: 0 }]).map((row) => (
<p key={row.currency} className="text-xl font-display tabular-nums">
{formatCurrency(row.total_expenses, row.currency)}
</p>
))}
</div>
</div>
{/* Bank account selector */}
{bankAccounts.length > 1 && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Landmark className="h-4 w-4 text-muted-foreground" />
Bankkonto
</Label>
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger className="w-full sm:w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bankAccounts.map((account) => (
<SelectItem key={account.account_number} value={account.account_number}>
<span className="font-mono">{account.account_number}</span>
{' '}
{account.account_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Välj vilket bankkonto transaktionerna ska bokföras mot.
</p>
</div>
)}
{/* Additional info */}
{refsCount > 0 && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link2 className="h-3 w-3" />
{refsCount} med OCR/referens
</div>
)}
</CardContent>
</Card>
{/* Duplicate rows: repeated here because the generic_csv path skips the
preview step where the same card is shown. Advisory: ingest skips
them automatically at execute. */}
{duplicateCount > 0 && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2 text-warning">
<AlertTriangle className="h-4 w-4" />
{t('import_duplicate_rows_title', { count: duplicateCount })}
</CardTitle>
<CardDescription>
{t('import_duplicate_rows_body')}
</CardDescription>
</CardHeader>
</Card>
)}
{/* Skipped rows: surfaced here because the manual-mapping path skips the
preview step where these warnings would otherwise be shown. */}
{warnings.length > 0 && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning" />
{warnings.length} {warnings.length === 1 ? 'rad' : 'rader'} hoppades över
</CardTitle>
<CardDescription>
Dessa rader kunde inte läsas och importeras inte. Kontrollera att inga transaktioner saknas.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-1 max-h-32 overflow-y-auto">
{warnings.slice(0, 10).map((issue, i) => (
<p key={i} className="text-xs text-muted-foreground">
Rad {issue.row}: {issue.message}
</p>
))}
{warnings.length > 10 && (
<p className="text-xs text-muted-foreground font-medium">
och {warnings.length - 10} till
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
className="min-h-11"
onClick={() => onExecute({
skip_duplicates: true,
auto_categorize: false,
settlement_account: selectedAccount !== '1930' ? selectedAccount : undefined,
})}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importerar...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Importera {stats.parsed_rows - duplicateCount} transaktioner
</>
)}
</Button>
</div>
</div>
)
}