Files
accounted/components/bookkeeping/ChartOfAccountsManager.tsx
T
Jakob Wennberg 66a4027f1e feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup
- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:19:56 +01:00

599 lines
23 KiB
TypeScript

'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: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
const TYPE_LABELS: Record<string, string> = {
asset: 'Tillgång',
liability: 'Skuld',
equity: 'EK',
revenue: 'Intakt',
expense: 'Kostnad',
untaxed_reserves: 'Ob. reserver',
}
// ---------------------------------------------------------------------------
// 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())
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
// 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()])
// Set K2 filter default based on company settings (plan_type)
if (hideK2Excluded === null) {
try {
const res = await fetch('/api/settings')
if (res.ok) {
const { data } = await res.json()
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
setHideK2Excluded(data?.plan_type === 'k1')
} else {
setHideK2Excluded(false)
}
} catch {
setHideK2Excluded(false)
}
}
setLoading(false)
}
load()
}, [fetchAccounts, fetchReference, hideK2Excluded])
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(() => {
let filtered = referenceAccounts
if (hideK2Excluded) {
filtered = filtered.filter((a) => !a.k2_excluded)
}
if (searchQuery) {
const q = searchQuery.toLowerCase()
filtered = filtered.filter(
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
)
}
return filtered
}, [referenceAccounts, searchQuery, hideK2Excluded])
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>
)}
{view === 'bas-catalog' && (
<label className="flex items-center gap-2 text-sm">
<Switch
checked={hideK2Excluded ?? false}
onCheckedChange={setHideK2Excluded}
className="scale-75"
/>
<span className="text-muted-foreground">Dölj K2-undantagna</span>
</label>
)}
</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="Sök 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 sökningen' : '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" />
)}
Lägg till
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)
})}
{filteredReference.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga konton matchar sökningen
</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>
)
}