feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination

Per-line VAT rates:
- Add generatePerRateLines() to group invoice items by vat_rate with separate
  revenue + VAT lines per rate group (invoice-entries.ts)
- Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts)
- PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices
- Invoice create/review UI supports per-line rate selection
- Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput

Invoice document types (proforma, delivery note):
- Add InvoiceDocumentType, document_type and converted_from_id to Invoice type
- PDF hides prices for delivery notes, adds proforma notice
- Email templates support all document types
- mark-paid skips journal entries for non-invoice document types
- Migration 031: invoice_document_type

Accounting method support:
- Add AccountingMethod type (accrual/cash)
- Migration 032: add_accounting_method column to company_settings

VAT declaration rewrite:
- Rewrite to read directly from general ledger (26xx/3xxx account lines)
  instead of aggregating invoices/transactions/receipts
- ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances

Bank reconciliation:
- Transaction ingest now pre-fetches unlinked GL lines and attempts
  auto-reconciliation during import
- Add transaction.reconciled event type
- Add ReconciliationMethod type and reconciliation_method on Transaction
- Migration 030: bank_reconciliation
- New reconciliation engine, API routes, and BankReconciliationView component

Pagination (fetchAllRows):
- New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit
- Adopted in all report generators, SIE/SRU export, account list APIs

Fiscal period validation:
- New validate-period-duration.ts enforces max 18 months per BFL 3 kap.
- Applied in period-service.ts and fiscal-periods API

Account mapper simplification:
- Remove Levenshtein/fuzzy matching, use exact account number match only

Swedbank parser improvements:
- Support abbreviated headers (Clnr, Bokfdag, Radnr)
- Use Referens column as counterparty

Chart of accounts management:
- Add DELETE endpoint with system account and usage protection
- PUT uses partial updates
- New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager

Tax deadline corrections:
- Rewrite inkomstdeklaration_ab using Skatteverket lookup table
- Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3

Onboarding first fiscal year:
- Add first fiscal year toggle with date pickers and 18-month validation

UI terminology:
- Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout

Report column fix:
- Fix start_date/end_date to period_start/period_end in report queries

Supplier invoice input:
- CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept)

Misc:
- SIE import uses upsert for idempotent account creation
- account-descriptions.ts falls back to BAS reference data
- Add invoice_default_notes to CompanySettings
- Update CLAUDE.md to reflect current project state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-21 14:57:15 +01:00
co-authored by Claude Opus 4.6
parent f1d187c005
commit 91e2c1705a
106 changed files with 9950 additions and 1641 deletions
+218
View File
@@ -0,0 +1,218 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import type { BASAccount } from '@/types'
interface AccountComboboxProps {
value: string
accounts: BASAccount[]
onChange: (accountNumber: string) => void
}
const MAX_RESULTS = 50
export default function AccountCombobox({ value, accounts, onChange }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Sync external value changes into the search field
useEffect(() => {
setSearch(value)
}, [value])
// Filter accounts based on search input
const filteredAccounts = useMemo(() => {
if (!search) return accounts.slice(0, MAX_RESULTS)
const trimmed = search.trim()
if (!trimmed) return accounts.slice(0, MAX_RESULTS)
const startsWithDigit = /^\d/.test(trimmed)
if (startsWithDigit) {
return accounts
.filter((a) => a.account_number.startsWith(trimmed))
.slice(0, MAX_RESULTS)
}
const lowerSearch = trimmed.toLowerCase()
return accounts
.filter((a) => a.account_name.toLowerCase().includes(lowerSearch))
.slice(0, MAX_RESULTS)
}, [accounts, search])
// Group filtered accounts by class
const groupedAccounts = useMemo(() => {
const groups: { className: string; accounts: BASAccount[] }[] = []
const groupMap = new Map<string, BASAccount[]>()
for (const account of filteredAccounts) {
const className = getAccountClassName(account.account_class)
if (!groupMap.has(className)) {
groupMap.set(className, [])
}
groupMap.get(className)!.push(account)
}
for (const [className, accts] of groupMap) {
groups.push({ className, accounts: accts })
}
return groups
}, [filteredAccounts])
// Flat list for keyboard navigation
const flatList = useMemo(() => filteredAccounts, [filteredAccounts])
// Reset highlight when filtered results change
useEffect(() => {
setHighlightedIndex(0)
}, [filteredAccounts])
// Scroll highlighted item into view
useEffect(() => {
if (!isOpen || !listRef.current) return
const highlighted = listRef.current.querySelector('[data-highlighted="true"]')
if (highlighted) {
highlighted.scrollIntoView({ block: 'nearest' })
}
}, [highlightedIndex, isOpen])
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const selectAccount = useCallback(
(accountNumber: string) => {
onChange(accountNumber)
setSearch(accountNumber)
setIsOpen(false)
inputRef.current?.blur()
},
[onChange]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
setIsOpen(true)
e.preventDefault()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((prev) => Math.min(prev + 1, flatList.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (flatList[highlightedIndex]) {
selectAccount(flatList[highlightedIndex].account_number)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
break
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setSearch(newValue)
onChange(newValue)
if (!isOpen) {
setIsOpen(true)
}
}
const handleFocus = () => {
setIsOpen(true)
}
// Find matching account for helper text
const matchedAccount = useMemo(() => {
if (!value || value.length !== 4) return null
return accounts.find((a) => a.account_number === value) || null
}, [value, accounts])
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
value={search}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="1930"
className="font-mono h-8"
maxLength={4}
autoComplete="off"
/>
{/* Account name helper text (md+ screens only) */}
{matchedAccount && (
<p className="hidden md:block text-[11px] text-muted-foreground truncate mt-0.5 leading-tight">
{matchedAccount.account_name}
</p>
)}
{/* Dropdown */}
{isOpen && flatList.length > 0 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 w-64 max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
>
{groupedAccounts.map((group) => (
<div key={group.className}>
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
{group.className}
</div>
{group.accounts.map((account) => {
const flatIndex = flatList.indexOf(account)
const isHighlighted = flatIndex === highlightedIndex
return (
<button
key={account.account_number}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
}`}
onMouseDown={(e) => {
e.preventDefault()
selectAccount(account.account_number)
}}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="truncate">{account.account_name}</span>
</button>
)
})}
</div>
))}
</div>
)}
</div>
)
}
+225
View File
@@ -0,0 +1,225 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
interface AddAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}
function deriveAccountType(accountNumber: string): { type: string; balance: string } {
const cls = parseInt(accountNumber[0])
switch (cls) {
case 1: return { type: 'asset', balance: 'debit' }
case 2: {
const group = parseInt(accountNumber.substring(0, 2))
if (group <= 20) return { type: 'equity', balance: 'credit' }
return { type: 'liability', balance: 'credit' }
}
case 3: return { type: 'revenue', balance: 'credit' }
case 4: case 5: case 6: case 7: return { type: 'expense', balance: 'debit' }
case 8: {
const group = parseInt(accountNumber.substring(0, 2))
if (group >= 83 && group <= 83) return { type: 'revenue', balance: 'credit' }
if (group >= 84 && group <= 84) return { type: 'expense', balance: 'debit' }
return { type: 'expense', balance: 'debit' }
}
default: return { type: 'expense', balance: 'debit' }
}
}
export function AddAccountDialog({ open, onOpenChange, onCreated }: AddAccountDialogProps) {
const [accountNumber, setAccountNumber] = useState('')
const [accountName, setAccountName] = useState('')
const [description, setDescription] = useState('')
const [defaultVatCode, setDefaultVatCode] = useState('')
const [sruCode, setSruCode] = useState('')
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const isBASMatch = accountNumber.length === 4 && isStandardBASAccount(accountNumber)
const derived = accountNumber.length === 4 ? deriveAccountType(accountNumber) : null
async function handleCreate() {
setError('')
if (!/^\d{4}$/.test(accountNumber)) {
setError('Kontonumret måste vara exakt 4 siffror')
return
}
if (!accountName.trim()) {
setError('Kontonamn krävs')
return
}
setIsSaving(true)
try {
const response = await fetch('/api/bookkeeping/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_number: accountNumber,
account_name: accountName.trim(),
account_type: derived?.type || 'expense',
normal_balance: normalBalance,
description: description || null,
default_vat_code: defaultVatCode || null,
sru_code: sruCode || null,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte skapa kontot')
}
// Reset form
setAccountNumber('')
setAccountName('')
setDescription('')
setDefaultVatCode('')
setSruCode('')
onCreated()
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Något gick fel')
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lägg till eget konto</DialogTitle>
<DialogDescription>
Skapa ett eget konto utanför BAS-standarden
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{isBASMatch && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">
Kontonummer {accountNumber} finns i BAS-standarden. Använd &quot;BAS-katalog&quot;-fliken för att aktivera standardkonton istället.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Kontonummer</Label>
<Input
value={accountNumber}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4)
setAccountNumber(v)
if (v.length === 4) {
const d = deriveAccountType(v)
setNormalBalance(d.balance as 'debit' | 'credit')
}
}}
placeholder="T.ex. 1935"
maxLength={4}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label>Normal saldo</Label>
<Select value={normalBalance} onValueChange={(v) => setNormalBalance(v as 'debit' | 'credit')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{derived && (
<p className="text-xs text-muted-foreground">
Auto-detekterad typ:{' '}
<span className="font-medium">
{derived.type === 'asset' ? 'Tillgång' : derived.type === 'liability' ? 'Skuld' : derived.type === 'equity' ? 'Eget kapital' : derived.type === 'revenue' ? 'Intäkt' : 'Kostnad'}
</span>
</p>
)}
<div className="space-y-2">
<Label>Kontonamn</Label>
<Input
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
placeholder="T.ex. Sparkonto företag"
/>
</div>
<div className="space-y-2">
<Label>Beskrivning <span className="text-muted-foreground">(valfritt)</span></Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kort beskrivning av kontots användning"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Momskod <span className="text-muted-foreground">(valfritt)</span></Label>
<Input
value={defaultVatCode}
onChange={(e) => setDefaultVatCode(e.target.value)}
placeholder="T.ex. MP1"
/>
</div>
<div className="space-y-2">
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
<Input
value={sruCode}
onChange={(e) => setSruCode(e.target.value)}
placeholder="T.ex. 7201"
/>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isSaving || accountNumber.length !== 4 || !accountName.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa konto
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,564 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { AccountNumber } from '@/components/ui/account-number'
import { AddAccountDialog } from './AddAccountDialog'
import { EditAccountDialog } from './EditAccountDialog'
import {
Search,
ChevronDown,
ChevronRight,
Plus,
Pencil,
Trash2,
Loader2,
CheckCircle2,
BookOpen,
} from 'lucide-react'
import type { BASAccount } from '@/types'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ReferenceAccount extends BASReferenceAccount {
is_activated: boolean
is_active: boolean
is_system_account: boolean
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CLASS_LABELS: Record<number, string> = {
1: 'Tillgangar',
2: 'Eget kapital och skulder',
3: 'Rorelseintatker',
4: 'Varuinkop och material',
5: 'Ovriga externa kostnader',
6: 'Ovriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
const TYPE_LABELS: Record<string, string> = {
asset: 'Tillgang',
liability: 'Skuld',
equity: 'EK',
revenue: 'Intakt',
expense: 'Kostnad',
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function ChartOfAccountsManager() {
const { toast } = useToast()
// View state
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
const [searchQuery, setSearchQuery] = useState('')
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
// Data state
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [referenceAccounts, setReferenceAccounts] = useState<ReferenceAccount[]>([])
const [loading, setLoading] = useState(true)
// Dialog state
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [editAccount, setEditAccount] = useState<BASAccount | null>(null)
// Action states
const [togglingAccount, setTogglingAccount] = useState<string | null>(null)
const [deletingAccount, setDeletingAccount] = useState<string | null>(null)
const [activatingAccounts, setActivatingAccounts] = useState<Set<string>>(new Set())
// -------------------------------------------
// Data fetching
// -------------------------------------------
const fetchAccounts = useCallback(async () => {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}, [])
const fetchReference = useCallback(async () => {
const res = await fetch('/api/bookkeeping/accounts/reference')
const { data } = await res.json()
setReferenceAccounts(data || [])
}, [])
useEffect(() => {
async function load() {
setLoading(true)
await Promise.all([fetchAccounts(), fetchReference()])
setLoading(false)
}
load()
}, [fetchAccounts, fetchReference])
const refreshAll = useCallback(async () => {
await Promise.all([fetchAccounts(), fetchReference()])
}, [fetchAccounts, fetchReference])
// -------------------------------------------
// Actions
// -------------------------------------------
async function toggleActive(account: BASAccount) {
setTogglingAccount(account.account_number)
try {
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !account.is_active }),
})
if (!res.ok) throw new Error('Kunde inte uppdatera kontot')
await refreshAll()
} catch {
toast({ title: 'Fel', description: 'Kunde inte uppdatera kontot', variant: 'destructive' })
} finally {
setTogglingAccount(null)
}
}
async function deleteAccount(account: BASAccount) {
setDeletingAccount(account.account_number)
try {
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
method: 'DELETE',
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Kunde inte ta bort kontot')
}
toast({ title: 'Konto borttaget', description: `${account.account_number} ${account.account_name}` })
await refreshAll()
} catch (err) {
toast({
title: 'Fel',
description: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
variant: 'destructive',
})
} finally {
setDeletingAccount(null)
}
}
async function activateBASAccount(accountNumber: string) {
setActivatingAccounts((prev) => new Set(prev).add(accountNumber))
try {
const res = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!res.ok) throw new Error('Kunde inte aktivera kontot')
const { activated } = await res.json()
if (activated > 0) {
toast({ title: 'Konto aktiverat', description: `Konto ${accountNumber} har lagts till i din kontoplan` })
}
await refreshAll()
} catch {
toast({ title: 'Fel', description: 'Kunde inte aktivera kontot', variant: 'destructive' })
} finally {
setActivatingAccounts((prev) => {
const next = new Set(prev)
next.delete(accountNumber)
return next
})
}
}
// -------------------------------------------
// Toggle class expansion
// -------------------------------------------
function toggleClass(cls: number) {
setExpandedClasses((prev) => {
const next = new Set(prev)
if (next.has(cls)) {
next.delete(cls)
} else {
next.add(cls)
}
return next
})
}
// -------------------------------------------
// Filtered & grouped data
// -------------------------------------------
const filteredAccounts = useMemo(() => {
if (!searchQuery) return accounts
const q = searchQuery.toLowerCase()
return accounts.filter(
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
)
}, [accounts, searchQuery])
const groupedAccounts = useMemo(() => {
const grouped: Record<number, BASAccount[]> = {}
for (const a of filteredAccounts) {
const cls = a.account_class
if (!grouped[cls]) grouped[cls] = []
grouped[cls].push(a)
}
return grouped
}, [filteredAccounts])
const filteredReference = useMemo(() => {
if (!searchQuery) return referenceAccounts
const q = searchQuery.toLowerCase()
return referenceAccounts.filter(
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
)
}, [referenceAccounts, searchQuery])
const groupedReference = useMemo(() => {
const grouped: Record<number, ReferenceAccount[]> = {}
for (const a of filteredReference) {
const cls = a.account_class
if (!grouped[cls]) grouped[cls] = []
grouped[cls].push(a)
}
return grouped
}, [filteredReference])
// -------------------------------------------
// Render
// -------------------------------------------
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Laddar kontoplan...
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Header controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs
value={view}
onValueChange={(v) => {
setView(v as 'my-accounts' | 'bas-catalog')
setExpandedClasses(new Set())
}}
>
<TabsList>
<TabsTrigger value="my-accounts">
Mina konton
<Badge variant="secondary" className="ml-1.5 text-xs">
{accounts.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="bas-catalog">
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
BAS-katalog
</TabsTrigger>
</TabsList>
</Tabs>
{view === 'my-accounts' && (
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Eget konto
</Button>
)}
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sok konto (nummer eller namn)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* My Accounts view */}
{view === 'my-accounts' && (
<div className="space-y-2">
{Object.entries(groupedAccounts)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
const activeCount = classAccounts.filter((a) => a.is_active).length
return (
<Card key={cls}>
<button
onClick={() => toggleClass(classNum)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
</span>
<Badge variant="secondary" className="text-xs">
{activeCount}/{classAccounts.length}
</Badge>
</div>
</button>
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-16 text-center">Aktiv</th>
<th className="py-2 w-20 text-right"></th>
</tr>
</thead>
<tbody>
{classAccounts.map((account) => (
<tr
key={account.id}
className={`border-b last:border-0 transition-opacity ${
!account.is_active ? 'opacity-50' : ''
}`}
>
<td className="py-2">
<AccountNumber number={account.account_number} name={account.account_name} />
</td>
<td className="py-2">
<span className="flex items-center gap-1.5">
{account.account_name}
{account.is_system_account && (
<Badge variant="outline" className="text-[10px] px-1 py-0">
System
</Badge>
)}
</span>
</td>
<td className="py-2 text-center">
<span className="text-xs font-mono text-muted-foreground">
{account.sru_code || '\u2014'}
</span>
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
</Badge>
</td>
<td className="py-2 text-center">
<Switch
checked={account.is_active}
onCheckedChange={() => toggleActive(account)}
disabled={togglingAccount === account.account_number}
className="scale-75"
/>
</td>
<td className="py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setEditAccount(account)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{!account.is_system_account && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => deleteAccount(account)}
disabled={deletingAccount === account.account_number}
>
{deletingAccount === account.account_number ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)
})}
{filteredAccounts.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
{searchQuery ? 'Inga konton matchar sokningen' : 'Inga konton i kontoplanen'}
</CardContent>
</Card>
)}
</div>
)}
{/* BAS Catalog view */}
{view === 'bas-catalog' && (
<div className="space-y-2">
{Object.entries(groupedReference)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
const activatedCount = classAccounts.filter((a) => a.is_activated).length
return (
<Card key={cls}>
<button
onClick={() => toggleClass(classNum)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
</span>
<Badge variant="secondary" className="text-xs">
{activatedCount}/{classAccounts.length} aktiva
</Badge>
</div>
</button>
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-28 text-right">Status</th>
</tr>
</thead>
<tbody>
{classAccounts.map((account) => (
<tr
key={account.account_number}
className={`border-b last:border-0 ${
account.is_activated ? 'bg-muted/30' : ''
}`}
>
<td className="py-2">
<AccountNumber number={account.account_number} name={account.account_name} />
</td>
<td className="py-2">
<div>
<span>{account.account_name}</span>
{account.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
{account.description}
</p>
)}
</div>
</td>
<td className="py-2 text-center">
<span className="text-xs font-mono text-muted-foreground">
{account.sru_code || '\u2014'}
</span>
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
</Badge>
</td>
<td className="py-2 text-right">
{account.is_activated ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
Aktiverat
</span>
) : (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => activateBASAccount(account.account_number)}
disabled={activatingAccounts.has(account.account_number)}
>
{activatingAccounts.has(account.account_number) ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Plus className="mr-1 h-3 w-3" />
)}
Lagg till
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)
})}
{filteredReference.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga konton matchar sokningen
</CardContent>
</Card>
)}
</div>
)}
{/* Dialogs */}
<AddAccountDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onCreated={refreshAll}
/>
{editAccount && (
<EditAccountDialog
open={!!editAccount}
onOpenChange={(open) => { if (!open) setEditAccount(null) }}
account={editAccount}
onSaved={refreshAll}
/>
)}
</div>
)
}
@@ -222,9 +222,14 @@ export default function DocumentUploadZone({
</Badge>
)}
{file.status === 'error' && (
<Badge variant="destructive" className="text-xs px-1.5 py-0" title={file.error}>
Fel
</Badge>
<>
<Badge variant="destructive" className="text-xs px-1.5 py-0">
Fel
</Badge>
{file.error && (
<span className="text-xs text-destructive">{file.error}</span>
)}
</>
)}
<Button
@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Loader2 } from 'lucide-react'
import type { BASAccount } from '@/types'
interface EditAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
account: BASAccount
onSaved: () => void
}
export function EditAccountDialog({ open, onOpenChange, account, onSaved }: EditAccountDialogProps) {
const [accountName, setAccountName] = useState(account.account_name)
const [description, setDescription] = useState(account.description || '')
const [defaultVatCode, setDefaultVatCode] = useState(account.default_vat_code || '')
const [sruCode, setSruCode] = useState(account.sru_code || '')
const [isActive, setIsActive] = useState(account.is_active)
const [isSaving, setIsSaving] = useState(false)
async function handleSave() {
setIsSaving(true)
try {
const response = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_name: accountName,
description: description || null,
default_vat_code: defaultVatCode || null,
sru_code: sruCode || null,
is_active: isActive,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte uppdatera kontot')
}
onSaved()
onOpenChange(false)
} catch {
// Error handled silently — toast is in parent
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Redigera konto {account.account_number}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Kontonamn</Label>
<Input
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kort beskrivning av kontots användning"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard momskod</Label>
<Input
value={defaultVatCode}
onChange={(e) => setDefaultVatCode(e.target.value)}
placeholder="T.ex. MP1"
/>
</div>
<div className="space-y-2">
<Label>SRU-kod</Label>
<Input
value={sruCode}
onChange={(e) => setSruCode(e.target.value)}
placeholder="T.ex. 7201"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Aktivt konto</p>
<p className="text-xs text-muted-foreground">
Inaktiva konton visas inte i bokföringsformulär
</p>
</div>
<Switch checked={isActive} onCheckedChange={setIsActive} />
</div>
{account.is_system_account && (
<p className="text-xs text-muted-foreground bg-muted rounded p-2">
Detta är ett systemkonto och kan inte tas bort.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={isSaving || !accountName.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+21 -7
View File
@@ -10,8 +10,9 @@ import { Plus, Trash2 } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { CreateJournalEntryLineInput, FiscalPeriod } from '@/types'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount } from '@/types'
interface Props {
onCreated?: () => void
@@ -37,11 +38,13 @@ export default function JournalEntryForm({ onCreated }: Props) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const isUploading = uploadedFiles.some((f) => f.status === 'uploading')
useEffect(() => {
fetchPeriods()
fetchAccounts()
}, [])
async function fetchPeriods() {
@@ -53,6 +56,12 @@ export default function JournalEntryForm({ onCreated }: Props) {
}
}
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}
const addLine = () => {
setLines([
...lines,
@@ -211,12 +220,10 @@ export default function JournalEntryForm({ onCreated }: Props) {
{lines.map((line, index) => (
<tr key={index} className="border-b">
<td className="py-1">
<Input
<AccountCombobox
value={line.account_number}
onChange={(e) => updateLine(index, 'account_number', e.target.value)}
placeholder="1930"
className="font-mono h-8"
maxLength={4}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
/>
</td>
<td className="py-1 px-1">
@@ -313,13 +320,20 @@ export default function JournalEntryForm({ onCreated }: Props) {
</p>
)}
<div className="flex justify-end">
<div className="flex flex-col items-end gap-1">
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading}
>
Granska & skapa
</Button>
{(!description || !selectedPeriod || isUploading) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{!description && <p>Ange en beskrivning</p>}
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
{isUploading && <p>Vänta tills filerna laddats upp</p>}
</div>
)}
</div>
<ConfirmationDialog