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:
@@ -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 & 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 "Konto",
|
||||
"Debet", "Kredit" eller "Saldo".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user