feat: add opening balance import functionality (#238)

- Implemented OpeningBalanceResultStep component to display results of the import process, including success messages and error handling.
- Created OpeningBalanceUploadStep component for file upload with drag-and-drop support, including validation for accepted file types.
- Developed column detection logic in column-detector.ts to identify account number, name, debit, credit, and balance columns based on headers and data.
- Added parser functionality in parser.ts to handle parsing of opening balance files, including validation and BAS account matching.
- Created tests for column detection and parsing logic to ensure accuracy and reliability.
- Defined types for detected columns and parsed rows in types.ts to improve type safety and clarity in the codebase.
This commit is contained in:
Mattsson
2026-04-14 15:35:50 +02:00
committed by GitHub
parent bf36ebfd88
commit a3fea6fb7c
19 changed files with 2865 additions and 19 deletions
@@ -0,0 +1,237 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import type { DetectedColumns, BalanceColumnLayout } from '@/lib/import/opening-balance/types'
interface OpeningBalanceColumnMappingStepProps {
headers: string[]
previewRows: string[][]
detectedColumns: DetectedColumns
onConfirm: (columns: DetectedColumns) => void
onBack: () => void
}
export default function OpeningBalanceColumnMappingStep({
headers,
previewRows,
detectedColumns,
onConfirm,
onBack,
}: OpeningBalanceColumnMappingStepProps) {
const [accountNumberCol, setAccountNumberCol] = useState(
detectedColumns.account_number_col,
)
const [accountNameCol, setAccountNameCol] = useState<number | null>(
detectedColumns.account_name_col,
)
const [layout, setLayout] = useState<BalanceColumnLayout>(
detectedColumns.layout,
)
const [balanceCol, setBalanceCol] = useState<number | null>(
detectedColumns.balance_col,
)
const [debitCol, setDebitCol] = useState<number | null>(
detectedColumns.debit_col,
)
const [creditCol, setCreditCol] = useState<number | null>(
detectedColumns.credit_col,
)
const columnOptions = headers.map((h, i) => ({
value: String(i),
label: `${i + 1}: ${h || '(tom)'}`,
}))
const canContinue =
accountNumberCol >= 0 &&
(layout === 'net' ? balanceCol !== null : debitCol !== null && creditCol !== null)
const handleConfirm = () => {
onConfirm({
account_number_col: accountNumberCol,
account_name_col: accountNameCol,
layout,
balance_col: layout === 'net' ? balanceCol : null,
debit_col: layout === 'debit_credit' ? debitCol : null,
credit_col: layout === 'debit_credit' ? creditCol : null,
confidence: 1, // User-confirmed
})
}
return (
<Card>
<CardHeader>
<CardTitle>Kolumnmappning</CardTitle>
<CardDescription>
Vi kunde inte automatiskt identifiera alla kolumner. Ange vilka kolumner
som innehåller kontonummer och belopp.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Layout toggle */}
<div className="space-y-2">
<Label>Beloppslayout</Label>
<Select value={layout} onValueChange={(v) => setLayout(v as BalanceColumnLayout)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="net">Nettobelopp (en kolumn)</SelectItem>
<SelectItem value="debit_credit">Debet &amp; kredit (två kolumner)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Required mappings */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Kontonummer *</Label>
<Select
value={String(accountNumberCol)}
onValueChange={(v) => setAccountNumberCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Kontonamn</Label>
<Select
value={accountNameCol !== null ? String(accountNameCol) : 'none'}
onValueChange={(v) => setAccountNameCol(v === 'none' ? null : Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">(ingen)</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{layout === 'net' && (
<div className="space-y-2">
<Label>Saldo/Belopp *</Label>
<Select
value={balanceCol !== null ? String(balanceCol) : ''}
onValueChange={(v) => setBalanceCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{layout === 'debit_credit' && (
<>
<div className="space-y-2">
<Label>Debet *</Label>
<Select
value={debitCol !== null ? String(debitCol) : ''}
onValueChange={(v) => setDebitCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Kredit *</Label>
<Select
value={creditCol !== null ? String(creditCol) : ''}
onValueChange={(v) => setCreditCol(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
{/* Preview */}
{previewRows.length > 0 && (
<div className="space-y-2">
<Label className="text-muted-foreground">Förhandsgranskning (5 första raderna)</Label>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left font-medium whitespace-nowrap">
{h || `Kolumn ${i + 1}`}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.slice(0, 5).map((row, ri) => (
<tr key={ri} className="border-b last:border-0">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-1.5 whitespace-nowrap tabular-nums">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={handleConfirm} disabled={!canContinue}>
Fortsätt
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,400 @@
'use client'
import { useState, useMemo, useCallback, useRef } from 'react'
import Fuse from 'fuse.js'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { ParsedOpeningBalanceRow } from '@/lib/import/opening-balance/types'
interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
validation_errors: string[]
bas_match: string | null
}
interface OpeningBalanceEditStepProps {
rows: ParsedOpeningBalanceRow[]
onContinue: (rows: EditableRow[]) => void
onBack: () => void
}
// Filter BAS reference to balance sheet accounts only (class 1-2) for primary suggestions
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
let fuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return fuseInstance
}
let balanceFuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getBalanceFuse() {
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return balanceFuseInstance
}
let idCounter = 0
function generateId() {
return `row_${++idCounter}_${Date.now()}`
}
export default function OpeningBalanceEditStep({
rows: initialRows,
onContinue,
onBack,
}: OpeningBalanceEditStepProps) {
const [rows, setRows] = useState<EditableRow[]>(() =>
initialRows.map((r) => ({
id: generateId(),
account_number: r.account_number,
account_name: r.account_name,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
validation_errors: r.validation_errors,
bas_match: r.bas_match,
})),
)
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(null)
// Compute totals
const totals = useMemo(() => {
let debit = 0
let credit = 0
for (const row of rows) {
debit = Math.round((debit + row.debit_amount) * 100) / 100
credit = Math.round((credit + row.credit_amount) * 100) / 100
}
const diff = Math.round((debit - credit) * 100) / 100
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
}, [rows])
// Validation
const hasErrors = useMemo(() => {
return rows.some((r) => {
if (!/^\d{4}$/.test(r.account_number)) return true
if (r.debit_amount === 0 && r.credit_amount === 0) return true
if (r.validation_errors.length > 0) return true
return false
})
}, [rows])
const canContinue = totals.isBalanced && !hasErrors && rows.length >= 2
// Autocomplete results
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
// If the query is numeric, search all accounts; otherwise prefer balance sheet
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
const updated = { ...r, ...updates }
// Re-validate
const errors: string[] = []
if (!/^\d{4}$/.test(updated.account_number)) {
errors.push('Ogiltigt kontonummer')
}
const cls = parseInt(updated.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) {
errors.push(`Resultatkonto (klass ${cls})`)
}
updated.validation_errors = errors
return updated
}),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
id: generateId(),
account_number: '',
account_name: '',
debit_amount: 0,
credit_amount: 0,
validation_errors: ['Ogiltigt kontonummer'],
bas_match: null,
},
])
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
bas_match: account.account_name,
})
setActiveAutocomplete(null)
setAutocompleteQuery('')
},
[updateRow],
)
const handleAutoBalance = useCallback(() => {
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
if (totals.isBalanced) return
const adjustmentRow: EditableRow = {
id: generateId(),
account_number: '2099',
account_name: 'Årets resultat',
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
credit_amount: totals.diff > 0 ? totals.diff : 0,
validation_errors: [],
bas_match: 'Årets resultat',
}
setRows((prev) => [...prev, adjustmentRow])
}, [totals])
return (
<Card>
<CardHeader>
<CardTitle>Granska och redigera</CardTitle>
<CardDescription>
Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort
och ändra rader. Debet och kredit måste balansera innan du kan fortsätta.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium w-28">Konto</th>
<th className="px-3 py-2 text-left font-medium">Kontonamn</th>
<th className="px-3 py-2 text-right font-medium w-32">Debet</th>
<th className="px-3 py-2 text-right font-medium w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
updateRow(row.id, { account_number: val })
setActiveAutocomplete(row.id)
setAutocompleteQuery(val)
}}
onFocus={() => {
setActiveAutocomplete(row.id)
setAutocompleteQuery(row.account_number)
}}
onBlur={() => {
// Delay to allow click on autocomplete items
setTimeout(() => setActiveAutocomplete(null), 200)
}}
placeholder="1930"
className="h-8 font-mono tabular-nums w-20"
maxLength={4}
/>
{/* Autocomplete dropdown */}
{activeAutocomplete === row.id &&
autocompleteResults.length > 0 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">
{row.account_name}
</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK.
Kontrollera beloppen innan du fortsätter.
</p>
</div>
)}
{/* Navigation */}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={() => onContinue(rows)} disabled={!canContinue}>
Fortsätt
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,193 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { AlertCircle, Loader2, CheckCircle2 } from 'lucide-react'
import type { FiscalPeriod } from '@/types'
interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
}
interface OpeningBalancePeriodStepProps {
rows: EditableRow[]
onExecute: (fiscalPeriodId: string) => void
onBack: () => void
isLoading: boolean
error: string | null
}
export default function OpeningBalancePeriodStep({
rows,
onExecute,
onBack,
isLoading,
error,
}: OpeningBalancePeriodStepProps) {
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('')
const [loadingPeriods, setLoadingPeriods] = useState(true)
// Compute totals
let totalDebit = 0
let totalCredit = 0
for (const row of rows) {
totalDebit = Math.round((totalDebit + row.debit_amount) * 100) / 100
totalCredit = Math.round((totalCredit + row.credit_amount) * 100) / 100
}
useEffect(() => {
async function fetchPeriods() {
setLoadingPeriods(true)
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (res.ok) {
const data = await res.json()
const allPeriods: FiscalPeriod[] = data.data || []
setPeriods(allPeriods)
// Auto-select first open period without OB
const openPeriod = allPeriods.find(
(p) => !p.is_closed && !p.locked_at && !p.opening_balances_set,
)
if (openPeriod) {
setSelectedPeriodId(openPeriod.id)
}
}
} catch {
// Silent — user can still select period
} finally {
setLoadingPeriods(false)
}
}
fetchPeriods()
}, [])
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
const periodHasOB = selectedPeriod?.opening_balances_set
const periodIsClosed = selectedPeriod?.is_closed
const periodIsLocked = !!selectedPeriod?.locked_at
const canExecute =
selectedPeriodId &&
!periodHasOB &&
!periodIsClosed &&
!periodIsLocked &&
!isLoading
const handleExecute = useCallback(() => {
if (canExecute) {
onExecute(selectedPeriodId)
}
}, [canExecute, selectedPeriodId, onExecute])
return (
<Card>
<CardHeader>
<CardTitle>Välj räkenskapsperiod</CardTitle>
<CardDescription>
Välj vilken räkenskapsperiod de ingående balanserna ska bokföras på.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Period selector */}
<div className="space-y-2">
<Label>Räkenskapsperiod</Label>
{loadingPeriods ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin" />
Hämtar perioder...
</div>
) : periods.length === 0 ? (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Inga räkenskapsperioder hittades. Skapa en räkenskapsperiod under Bokföring först.
</p>
</div>
) : (
<Select value={selectedPeriodId} onValueChange={setSelectedPeriodId}>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id} disabled={p.is_closed || !!p.locked_at}>
{p.name} ({p.period_start} — {p.period_end})
{p.opening_balances_set && ' — har redan IB'}
{p.is_closed && ' — stängd'}
{p.locked_at && !p.is_closed && ' — låst'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Warnings */}
{periodHasOB && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">
Denna period har redan ingående balanser. Ta bort den befintliga verifikationen
(via stornering) innan du importerar nya.
</p>
</div>
)}
{/* Summary */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<h4 className="text-sm font-medium">Sammanfattning</h4>
<div className="grid grid-cols-2 gap-y-2 text-sm">
<span className="text-muted-foreground">Antal konton:</span>
<span className="tabular-nums text-right">{rows.length}</span>
<span className="text-muted-foreground">Total debet:</span>
<span className="tabular-nums text-right">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
</span>
<span className="text-muted-foreground">Total kredit:</span>
<span className="tabular-nums text-right">
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
</span>
</div>
<div className="flex items-center gap-2 pt-1 border-t text-sm">
<CheckCircle2 className="h-4 w-4 text-success" />
<span className="text-success font-medium">Balanserar</span>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack} disabled={isLoading}>
Tillbaka
</Button>
<Button onClick={handleExecute} disabled={!canExecute}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Bokför...
</>
) : (
'Bokför ingående balanser'
)}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,113 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle2, XCircle, ArrowRight } from 'lucide-react'
import Link from 'next/link'
import type { OpeningBalanceExecuteResult } from '@/lib/import/opening-balance/types'
interface OpeningBalanceResultStepProps {
result: OpeningBalanceExecuteResult
onNewImport: () => void
}
export default function OpeningBalanceResultStep({
result,
onNewImport,
}: OpeningBalanceResultStepProps) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
{result.success ? (
<CheckCircle2 className="h-6 w-6 text-success" />
) : (
<XCircle className="h-6 w-6 text-destructive" />
)}
<CardTitle>
{result.success
? 'Ingående balanser bokförda'
: 'Importen misslyckades'}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{result.success ? (
<>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg border bg-muted/30 p-4 text-center">
<p className="text-2xl font-semibold tabular-nums">
{result.lines_created}
</p>
<p className="text-sm text-muted-foreground mt-1">Kontorader</p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 text-center">
<p className="text-2xl font-semibold tabular-nums">
{result.total_debit.toLocaleString('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}
</p>
<p className="text-sm text-muted-foreground mt-1">Debet (SEK)</p>
</div>
<div className="rounded-lg border bg-muted/30 p-4 text-center">
<p className="text-2xl font-semibold tabular-nums">
{result.total_credit.toLocaleString('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}
</p>
<p className="text-sm text-muted-foreground mt-1">Kredit (SEK)</p>
</div>
</div>
{/* Next steps */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Nästa steg</h4>
<ol className="text-sm text-muted-foreground space-y-1.5 list-decimal list-inside">
<li>Granska verifikationen i bokföringen</li>
<li>Kontrollera balansrapporten att beloppen stämmer</li>
<li>Börja importera banktransaktioner eller skapa verifikationer</li>
</ol>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
{result.journal_entry_id && (
<Button asChild>
<Link href={`/bookkeeping/${result.journal_entry_id}`}>
Visa verifikation
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
)}
<Button variant="outline" asChild>
<Link href="/reports">
Visa rapporter
</Link>
</Button>
<Button variant="ghost" onClick={onNewImport}>
Ny import
</Button>
</div>
</>
) : (
<>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<p className="text-sm text-destructive">
{result.error || 'Ett okänt fel uppstod vid importen.'}
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={onNewImport}>
Försök igen
</Button>
</div>
</>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,130 @@
'use client'
import { useCallback, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Upload, FileSpreadsheet, AlertCircle, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface OpeningBalanceUploadStepProps {
onFileSelect: (file: File) => void
isLoading: boolean
error: string | null
}
export default function OpeningBalanceUploadStep({
onFileSelect,
isLoading,
error,
}: OpeningBalanceUploadStepProps) {
const [isDragging, setIsDragging] = useState(false)
const ACCEPTED_TYPES = '.xlsx,.xls,.csv,.ods'
const handleFile = useCallback((file: File) => {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !['xlsx', 'xls', 'csv', 'ods'].includes(ext)) {
return
}
onFileSelect(file)
}, [onFileSelect])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}, [handleFile])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback(() => {
setIsDragging(false)
}, [])
return (
<Card>
<CardHeader>
<CardTitle>Ladda upp fil med ingående balanser</CardTitle>
<CardDescription>
Ladda upp en Excel- eller CSV-fil med ditt företags ingående balanser.
Filen bör innehålla kontonummer och belopp (debet/kredit eller saldo).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Drop zone */}
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/20 hover:border-muted-foreground/40',
isLoading && 'pointer-events-none opacity-60',
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{isLoading ? (
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Läser fil och identifierar kolumner...</p>
</div>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground/50 mb-3" />
<p className="text-sm font-medium">
Dra och släpp din fil här
</p>
<p className="text-sm text-muted-foreground mt-1">
eller
</p>
<label>
<input
type="file"
accept={ACCEPTED_TYPES}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
e.target.value = ''
}}
/>
<Button variant="outline" size="sm" className="mt-2" asChild>
<span>Välj fil</span>
</Button>
</label>
<p className="text-xs text-muted-foreground mt-3">
XLSX, XLS, CSV, ODS — max 10 MB
</p>
</>
)}
</div>
{/* Error */}
{error && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Info */}
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
<FileSpreadsheet className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-sm text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Filformat</p>
<p>
Filen bör ha en rubrikrad med kolumner för kontonummer och belopp.
Vanliga format stöds automatiskt — t.ex. kolumner som heter &quot;Konto&quot;,
&quot;Debet&quot;, &quot;Kredit&quot; eller &quot;Saldo&quot;.
</p>
</div>
</div>
</CardContent>
</Card>
)
}