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:
co-authored by
Claude Opus 4.6
parent
f1d187c005
commit
91e2c1705a
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "BAS-katalog"-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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user