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
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
<div>
|
||||
<p className="font-medium text-sm">Transaktioner</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedCount} okategoriserade
|
||||
{summary.uncategorizedCount} obokförda
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
|
||||
{ href: '/receipts/scan', icon: Camera, label: 'Skanna kvitto', desc: 'Fotografera & spara' },
|
||||
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
|
||||
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Kategorisera' },
|
||||
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -220,7 +220,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
|
||||
}
|
||||
if (summary.uncategorizedCount > 0) {
|
||||
todoItems.push({ label: 'okategoriserade', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
|
||||
todoItems.push({ label: 'obokförda', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
|
||||
}
|
||||
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
|
||||
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
|
||||
@@ -465,7 +465,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">
|
||||
{summary.uncategorizedCount} okategoriserade transaktioner
|
||||
{summary.uncategorizedCount} obokförda transaktioner
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedIncome > 0 && (
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { AccountMapping } from '@/lib/import/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
|
||||
|
||||
interface AccountMappingStepProps {
|
||||
mappings: AccountMapping[]
|
||||
@@ -352,25 +353,3 @@ function ConfidenceBadge({
|
||||
return <Badge variant="outline">Osäker</Badge>
|
||||
}
|
||||
|
||||
function getAccountClassName(accountClass: number): string {
|
||||
switch (accountClass) {
|
||||
case 1:
|
||||
return '1xxx - Tillgångar'
|
||||
case 2:
|
||||
return '2xxx - Eget kapital & Skulder'
|
||||
case 3:
|
||||
return '3xxx - Intäkter'
|
||||
case 4:
|
||||
return '4xxx - Varuinköp'
|
||||
case 5:
|
||||
return '5xxx - Externa kostnader'
|
||||
case 6:
|
||||
return '6xxx - Övriga externa kostnader'
|
||||
case 7:
|
||||
return '7xxx - Personal'
|
||||
case 8:
|
||||
return '8xxx - Finansiella poster'
|
||||
default:
|
||||
return `${accountClass}xxx - Övrigt`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function BankFileConfirmStep({
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="auto-categorize" className="text-sm font-medium cursor-pointer">
|
||||
Auto-kategorisera kända transaktioner
|
||||
Auto-bokför kända transaktioner
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skapar automatiskt bokföringsposter för transaktioner med hög konfidens
|
||||
@@ -139,8 +139,8 @@ export default function BankFileConfirmStep({
|
||||
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Importerade transaktioner som inte automatiskt kategoriseras visas som
|
||||
"okategoriserade" på transaktionssidan. Du kan kategorisera dem manuellt
|
||||
Importerade transaktioner som inte automatiskt bokförs visas som
|
||||
"obokförda" på transaktionssidan. Du kan bokföra dem manuellt
|
||||
efteråt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +200,23 @@ export default function BankFilePreviewStep({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error blocking continuation */}
|
||||
{hasIssues && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Filen innehåller fel som förhindrar import</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Kontrollera felet ovan och försök ladda upp en korrigerad fil.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function BankFileResultStep({
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span className="text-sm">Auto-kategoriserade</span>
|
||||
<span className="text-sm">Auto-bokförda</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{result.auto_categorized}</p>
|
||||
</CardContent>
|
||||
@@ -108,11 +108,11 @@ export default function BankFileResultStep({
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Granska okategoriserade transaktioner</p>
|
||||
<p className="font-medium">Granska obokförda transaktioner</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.imported - result.auto_categorized > 0
|
||||
? `${result.imported - result.auto_categorized} transaktioner behöver kategoriseras manuellt.`
|
||||
: 'Alla transaktioner kategoriserades automatiskt.'}
|
||||
? `${result.imported - result.auto_categorized} transaktioner behöver bokföras manuellt.`
|
||||
: 'Alla transaktioner bokfördes automatiskt.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,13 +67,13 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<span className="text-sm">Räkenskapsår</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
<div className="text-2xl font-bold">
|
||||
{result.fiscalPeriodId ? (
|
||||
<Badge variant="default" className="bg-success">Skapat</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Befintligt</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -82,13 +82,13 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<span className="text-sm">Ingående balanser</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
<div className="text-2xl font-bold">
|
||||
{result.openingBalanceEntryId ? (
|
||||
<Badge variant="default" className="bg-success">Importerade</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Inga</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ReviewItem {
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate?: number
|
||||
}
|
||||
|
||||
interface InvoiceReviewContentProps {
|
||||
@@ -51,6 +52,24 @@ export function InvoiceReviewContent({
|
||||
non_eu_business: 'Utanför EU',
|
||||
}
|
||||
|
||||
// Check if items have mixed VAT rates
|
||||
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined)
|
||||
const uniqueRates = hasPerLineVat
|
||||
? new Set(items.map((item) => item.vat_rate ?? vatRate))
|
||||
: new Set([vatRate])
|
||||
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
|
||||
|
||||
// Calculate per-rate VAT breakdown
|
||||
const vatByRate = new Map<number, number>()
|
||||
if (hasPerLineVat) {
|
||||
for (const item of items) {
|
||||
const rate = item.vat_rate ?? vatRate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Customer info */}
|
||||
@@ -89,6 +108,7 @@ export function InvoiceReviewContent({
|
||||
<th className="py-2 w-16 text-right">Antal</th>
|
||||
<th className="py-2 w-16 text-center">Enhet</th>
|
||||
<th className="py-2 w-24 text-right">À-pris</th>
|
||||
{showVatColumn && <th className="py-2 w-16 text-right">Moms</th>}
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -99,6 +119,9 @@ export function InvoiceReviewContent({
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-center">{item.unit}</td>
|
||||
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
|
||||
{showVatColumn && (
|
||||
<td className="py-2 text-right">{item.vat_rate ?? vatRate}%</td>
|
||||
)}
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
</td>
|
||||
@@ -113,10 +136,23 @@ export function InvoiceReviewContent({
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span>{formatCurrency(subtotal, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms ({vatRate}%)</span>
|
||||
<span>{formatCurrency(vatAmount, currency)}</span>
|
||||
</div>
|
||||
{vatByRate.size > 1 ? (
|
||||
// Per-rate breakdown
|
||||
Array.from(vatByRate.entries())
|
||||
.filter(([, vat]) => vat > 0)
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rate, vat]) => (
|
||||
<div key={rate} className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||
<span>{formatCurrency(vat, currency)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms ({vatRate}%)</span>
|
||||
<span>{formatCurrency(vatAmount, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-2xl">
|
||||
<span>Totalt</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -10,47 +11,130 @@ import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
|
||||
import type { MomsPeriod } from '@/types'
|
||||
import { Loader2, ArrowRight, ArrowLeft, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MomsPeriod, EntityType } from '@/types'
|
||||
|
||||
const schema = z.object({
|
||||
f_skatt: z.boolean(),
|
||||
fiscal_year_start_month: z.number().min(1).max(12),
|
||||
is_first_fiscal_year: z.boolean(),
|
||||
// First year fields (conditional)
|
||||
first_year_start: z.string().optional(),
|
||||
first_year_end: z.string().optional(),
|
||||
// Ongoing year field (conditional)
|
||||
fiscal_year_end_month: z.number().min(1).max(12).optional(),
|
||||
// Existing fields
|
||||
vat_registered: z.boolean(),
|
||||
vat_number: z.string().optional(),
|
||||
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).optional(),
|
||||
accounting_method: z.enum(['accrual', 'cash']),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
// Output type passed to onNext — includes computed fiscal_year_start_month
|
||||
interface Step3Output {
|
||||
f_skatt: boolean
|
||||
fiscal_year_start_month: number
|
||||
is_first_fiscal_year: boolean
|
||||
first_year_start?: string
|
||||
first_year_end?: string
|
||||
vat_registered: boolean
|
||||
vat_number?: string
|
||||
moms_period?: MomsPeriod
|
||||
accounting_method: 'accrual' | 'cash'
|
||||
}
|
||||
|
||||
interface Step3Props {
|
||||
initialData: Partial<FormData>
|
||||
onNext: (data: FormData) => void
|
||||
initialData: Partial<Step3Output>
|
||||
entityType?: EntityType
|
||||
onNext: (data: Step3Output) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
const months = [
|
||||
{ value: 1, label: 'Januari' },
|
||||
{ value: 2, label: 'Februari' },
|
||||
{ value: 3, label: 'Mars' },
|
||||
{ value: 4, label: 'April' },
|
||||
{ value: 5, label: 'Maj' },
|
||||
{ value: 6, label: 'Juni' },
|
||||
{ value: 7, label: 'Juli' },
|
||||
{ value: 8, label: 'Augusti' },
|
||||
{ value: 9, label: 'September' },
|
||||
{ value: 10, label: 'Oktober' },
|
||||
{ value: 11, label: 'November' },
|
||||
{ value: 12, label: 'December' },
|
||||
const monthNames = [
|
||||
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the last day of a given month (1-indexed).
|
||||
*/
|
||||
function lastDayOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute valid first-year end dates for enskild firma.
|
||||
* EF must use calendar year, so end is always Dec 31.
|
||||
*/
|
||||
function getEFFirstYearEndDates(startYear: number, startMonth: number): { label: string; value: string }[] {
|
||||
// EF always ends Dec 31 of either same year or (if start is Jan) same year
|
||||
// If start is late in the year, only option is Dec 31 same year
|
||||
// Periods cannot exceed 18 months
|
||||
const options: { label: string; value: string }[] = []
|
||||
|
||||
// Option 1: Dec 31 of same year (if startMonth <= 12)
|
||||
const months1 = 12 - startMonth + 1
|
||||
if (months1 >= 1 && months1 <= 18) {
|
||||
const endDate = `${startYear}-12-31`
|
||||
options.push({
|
||||
label: `31 december ${startYear} (${months1} mån)`,
|
||||
value: endDate,
|
||||
})
|
||||
}
|
||||
|
||||
// Option 2: Dec 31 of next year (if that gives <= 18 months)
|
||||
const months2 = months1 + 12
|
||||
if (months2 >= 1 && months2 <= 18 && startMonth > 6) {
|
||||
// Only makes sense if start month > June (otherwise > 18 months)
|
||||
const endDate = `${startYear + 1}-12-31`
|
||||
options.push({
|
||||
label: `31 december ${startYear + 1} (${months2} mån)`,
|
||||
value: endDate,
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute valid first-year end dates for aktiebolag given a chosen end month.
|
||||
* Returns one or two options (ending in the nearest years that give 1-18 months).
|
||||
*/
|
||||
function getABFirstYearEndDates(
|
||||
startYear: number,
|
||||
startMonth: number,
|
||||
endMonth: number
|
||||
): { label: string; value: string }[] {
|
||||
const options: { label: string; value: string }[] = []
|
||||
|
||||
// Try ending in the same year or next year
|
||||
for (const endYear of [startYear, startYear + 1, startYear + 2]) {
|
||||
const months = (endYear - startYear) * 12 + (endMonth - startMonth) + 1
|
||||
if (months >= 1 && months <= 18) {
|
||||
const day = lastDayOfMonth(endYear, endMonth)
|
||||
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
options.push({
|
||||
label: `${day} ${monthNames[endMonth - 1].toLowerCase()} ${endYear} (${months} mån)`,
|
||||
value: endDate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
export default function Step3TaxRegistration({
|
||||
initialData,
|
||||
entityType,
|
||||
onNext,
|
||||
onBack,
|
||||
isSaving,
|
||||
}: Step3Props) {
|
||||
const isEF = entityType === 'enskild_firma'
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -61,14 +145,84 @@ export default function Step3TaxRegistration({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
f_skatt: initialData.f_skatt ?? true,
|
||||
fiscal_year_start_month: initialData.fiscal_year_start_month ?? 1,
|
||||
is_first_fiscal_year: initialData.is_first_fiscal_year ?? false,
|
||||
first_year_start: initialData.first_year_start || '',
|
||||
first_year_end: initialData.first_year_end || '',
|
||||
fiscal_year_end_month: initialData.fiscal_year_start_month
|
||||
? (initialData.fiscal_year_start_month === 1 ? 12 : initialData.fiscal_year_start_month - 1)
|
||||
: 12,
|
||||
vat_registered: initialData.vat_registered ?? false,
|
||||
vat_number: initialData.vat_number || '',
|
||||
moms_period: initialData.moms_period,
|
||||
accounting_method: initialData.accounting_method ?? 'accrual',
|
||||
},
|
||||
})
|
||||
|
||||
const vatRegistered = watch('vat_registered')
|
||||
const isFirstYear = watch('is_first_fiscal_year')
|
||||
const firstYearStart = watch('first_year_start')
|
||||
const firstYearEnd = watch('first_year_end')
|
||||
const fiscalYearEndMonth = watch('fiscal_year_end_month')
|
||||
|
||||
// State for AB first-year end month selector
|
||||
const [abEndMonth, setAbEndMonth] = useState<number>(
|
||||
initialData.first_year_end
|
||||
? new Date(initialData.first_year_end).getMonth() + 1
|
||||
: 12
|
||||
)
|
||||
|
||||
// Parse first year start for date computations
|
||||
const parsedStart = useMemo(() => {
|
||||
if (!firstYearStart) return null
|
||||
const d = new Date(firstYearStart)
|
||||
if (isNaN(d.getTime())) return null
|
||||
return { year: d.getFullYear(), month: d.getMonth() + 1 }
|
||||
}, [firstYearStart])
|
||||
|
||||
// Compute end date options for first year
|
||||
const firstYearEndOptions = useMemo(() => {
|
||||
if (!parsedStart) return []
|
||||
if (isEF) {
|
||||
return getEFFirstYearEndDates(parsedStart.year, parsedStart.month)
|
||||
}
|
||||
return getABFirstYearEndDates(parsedStart.year, parsedStart.month, abEndMonth)
|
||||
}, [parsedStart, isEF, abEndMonth])
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
let fiscalYearStartMonth: number
|
||||
let firstStart: string | undefined
|
||||
let firstEnd: string | undefined
|
||||
|
||||
if (data.is_first_fiscal_year && data.first_year_start && data.first_year_end) {
|
||||
// Derive start month from end date
|
||||
const endDate = new Date(data.first_year_end)
|
||||
const endMonth = endDate.getMonth() + 1
|
||||
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
|
||||
firstStart = data.first_year_start
|
||||
firstEnd = data.first_year_end
|
||||
} else if (isEF) {
|
||||
// EF must always be calendar year
|
||||
fiscalYearStartMonth = 1
|
||||
} else {
|
||||
// AB ongoing: derive from end month
|
||||
const endMonth = data.fiscal_year_end_month || 12
|
||||
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
|
||||
}
|
||||
|
||||
const output: Step3Output = {
|
||||
f_skatt: data.f_skatt,
|
||||
fiscal_year_start_month: fiscalYearStartMonth,
|
||||
is_first_fiscal_year: data.is_first_fiscal_year,
|
||||
...(firstStart && { first_year_start: firstStart }),
|
||||
...(firstEnd && { first_year_end: firstEnd }),
|
||||
vat_registered: data.vat_registered,
|
||||
vat_number: data.vat_number,
|
||||
moms_period: data.moms_period,
|
||||
accounting_method: data.accounting_method,
|
||||
}
|
||||
|
||||
onNext(output)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -87,7 +241,7 @@ export default function Step3TaxRegistration({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* F-skatt */}
|
||||
<div className="flex items-start space-x-3">
|
||||
<Controller
|
||||
@@ -122,33 +276,198 @@ export default function Step3TaxRegistration({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiscal year */}
|
||||
<div className="space-y-2">
|
||||
<Label>Räkenskapsår börjar</Label>
|
||||
{/* Fiscal year section */}
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Räkenskapsår</p>
|
||||
<p>Ditt räkenskapsår bestämmer vilken period du bokför för. De flesta har kalenderår (jan-dec).</p>
|
||||
{isEF && (
|
||||
<p className="text-xs text-muted-foreground">Enskild firma måste använda kalenderår enligt BFL 3 kap.</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label className="text-base font-medium">Vilket räkenskapsår bokför du för?</Label>
|
||||
</InfoTooltip>
|
||||
|
||||
{/* Toggle: First year vs Ongoing */}
|
||||
<Controller
|
||||
name="fiscal_year_start_month"
|
||||
name="is_first_fiscal_year"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj månad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((month) => (
|
||||
<SelectItem key={month.value} value={month.value.toString()}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => field.onChange(true)}
|
||||
className="text-left"
|
||||
>
|
||||
<Card className={cn(
|
||||
'p-3 transition-all cursor-pointer hover:border-primary/50',
|
||||
field.value && 'border-primary ring-2 ring-primary/20'
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Första räkenskapsåret</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Nystartat företag</p>
|
||||
</div>
|
||||
{field.value && (
|
||||
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => field.onChange(false)}
|
||||
className="text-left"
|
||||
>
|
||||
<Card className={cn(
|
||||
'p-3 transition-all cursor-pointer hover:border-primary/50',
|
||||
!field.value && 'border-primary ring-2 ring-primary/20'
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Annat räkenskapsår</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Pågående verksamhet</p>
|
||||
</div>
|
||||
{!field.value && (
|
||||
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
De flesta har kalenderår (januari). Brutet räkenskapsår är vanligare för aktiebolag.
|
||||
</p>
|
||||
|
||||
{/* First fiscal year options */}
|
||||
{isFirstYear && (
|
||||
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_year_start">Startdatum</Label>
|
||||
<Controller
|
||||
name="first_year_start"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="first_year_start"
|
||||
type="date"
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dagen verksamheten startade (bör vara den 1:a i en månad).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AB: end month selector */}
|
||||
{!isEF && parsedStart && (
|
||||
<div className="space-y-2">
|
||||
<Label>Räkenskapsåret slutar (månad)</Label>
|
||||
<Select
|
||||
value={abEndMonth.toString()}
|
||||
onValueChange={(v) => setAbEndMonth(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj månad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthNames.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End date selector (options depend on entity type + start) */}
|
||||
{parsedStart && firstYearEndOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Slutdatum</Label>
|
||||
<Controller
|
||||
name="first_year_end"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value || ''}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj slutdatum" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{firstYearEndOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedStart && firstYearEndOptions.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ongoing fiscal year options */}
|
||||
{!isFirstYear && (
|
||||
<div className="space-y-2">
|
||||
{isEF ? (
|
||||
<div className="rounded-lg bg-muted/50 p-4">
|
||||
<p className="text-sm font-medium">Kalenderår (januari-december)</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Enskild firma måste använda kalenderår enligt BFL 3 kap.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>När slutar företagets räkenskapsår?</Label>
|
||||
<Controller
|
||||
name="fiscal_year_end_month"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value?.toString() || '12'}
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj månad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthNames.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* VAT section */}
|
||||
@@ -209,7 +528,7 @@ export default function Step3TaxRegistration({
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Hur ofta rapporterar du moms?</p>
|
||||
<p>Osäker? Börja med kvartal - det är vanligast och du kan ändra senare.</p>
|
||||
<p>Välj den period som anges på Verksamt eller i ditt beslut från Skatteverket.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
|
||||
<li>1-40 miljoner = Kvartal</li>
|
||||
@@ -241,13 +560,48 @@ export default function Step3TaxRegistration({
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Osäker? Välj kvartal - det passar de flesta och du kan ändra senare.
|
||||
Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accounting method */}
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content="Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning."
|
||||
side="right"
|
||||
>
|
||||
<Label>Bokföringsmetod</Label>
|
||||
</InfoTooltip>
|
||||
<Controller
|
||||
name="accounting_method"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj metod" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accrual">Faktureringsmetoden</SelectItem>
|
||||
<SelectItem value="cash">Kontantmetoden</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entityType === 'aktiebolag'
|
||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
|
||||
: 'Som enskild firma med omsättning under 3 MSEK kan du välja kontantmetoden.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
auto_exact: 'Exakt matchning',
|
||||
auto_date_range: 'Datumintervall',
|
||||
auto_reference: 'Referensmatchning',
|
||||
auto_fuzzy: 'Ungefärlig matchning',
|
||||
manual: 'Manuell',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
interface ReconciliationStatus {
|
||||
bank_transaction_total: number
|
||||
gl_1930_balance: number
|
||||
difference: number
|
||||
is_reconciled: boolean
|
||||
matched_count: number
|
||||
unmatched_transaction_count: number
|
||||
unmatched_gl_line_count: number
|
||||
}
|
||||
|
||||
interface UnlinkedGLLine {
|
||||
line_id: string
|
||||
journal_entry_id: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description: string | null
|
||||
entry_date: string
|
||||
voucher_number: number
|
||||
voucher_series: string
|
||||
entry_description: string
|
||||
source_type: string
|
||||
}
|
||||
|
||||
interface UnmatchedTransaction {
|
||||
id: string
|
||||
date: string
|
||||
description: string
|
||||
amount: number
|
||||
reference: string | null
|
||||
currency: string
|
||||
}
|
||||
|
||||
interface MatchedTransaction {
|
||||
id: string
|
||||
date: string
|
||||
description: string
|
||||
amount: number
|
||||
reconciliation_method: string | null
|
||||
journal_entry_id: string | null
|
||||
}
|
||||
|
||||
interface DryRunMatch {
|
||||
transaction_id: string
|
||||
transaction_date: string
|
||||
transaction_description: string
|
||||
transaction_amount: number
|
||||
journal_entry_id: string
|
||||
voucher_number: number
|
||||
voucher_series: string
|
||||
entry_date: string
|
||||
entry_description: string
|
||||
method: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Component
|
||||
// ============================================================
|
||||
|
||||
export function BankReconciliationView() {
|
||||
const [status, setStatus] = useState<ReconciliationStatus | null>(null)
|
||||
const [unmatchedTx, setUnmatchedTx] = useState<UnmatchedTransaction[]>([])
|
||||
const [glLines, setGlLines] = useState<UnlinkedGLLine[]>([])
|
||||
const [matchedTx, setMatchedTx] = useState<MatchedTransaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
const [dryRunResults, setDryRunResults] = useState<DryRunMatch[] | null>(null)
|
||||
const [runLoading, setRunLoading] = useState(false)
|
||||
const [applyLoading, setApplyLoading] = useState(false)
|
||||
const [linkLoading, setLinkLoading] = useState<string | null>(null)
|
||||
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
|
||||
|
||||
const [showMatched, setShowMatched] = useState(false)
|
||||
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
const qs = params.toString() ? `?${params}` : ''
|
||||
|
||||
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
|
||||
fetch(`/api/reconciliation/bank/status${qs}`),
|
||||
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`),
|
||||
fetch(`/api/transactions?unmatched=true¤cy=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
fetch(`/api/transactions?reconciled=true¤cy=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
])
|
||||
|
||||
const [statusData, glData, unmatchedData, matchedData] = await Promise.all([
|
||||
statusRes.json(),
|
||||
glRes.json(),
|
||||
unmatchedRes.json(),
|
||||
matchedRes.json(),
|
||||
])
|
||||
|
||||
if (statusData.data) setStatus(statusData.data)
|
||||
setGlLines(glData.data || [])
|
||||
setUnmatchedTx(unmatchedData.data || [])
|
||||
setMatchedTx(matchedData.data || [])
|
||||
} catch {
|
||||
setError('Kunde inte hämta avstämningsdata')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [dateFrom, dateTo])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
}, [fetchAll])
|
||||
|
||||
const handleDryRun = async () => {
|
||||
setRunLoading(true)
|
||||
setDryRunResults(null)
|
||||
try {
|
||||
const res = await fetch('/api/reconciliation/bank/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
dry_run: true,
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.data?.matches) {
|
||||
setDryRunResults(result.data.matches)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte köra förhandsgranskning')
|
||||
} finally {
|
||||
setRunLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplyLoading(true)
|
||||
try {
|
||||
await fetch('/api/reconciliation/bank/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
dry_run: false,
|
||||
}),
|
||||
})
|
||||
setDryRunResults(null)
|
||||
await fetchAll()
|
||||
} catch {
|
||||
setError('Kunde inte tillämpa matchningar')
|
||||
} finally {
|
||||
setApplyLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualLink = async (transactionId: string) => {
|
||||
const journalEntryId = selectedMatch[transactionId]
|
||||
if (!journalEntryId) return
|
||||
|
||||
setLinkLoading(transactionId)
|
||||
try {
|
||||
const res = await fetch('/api/reconciliation/bank/link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transaction_id: transactionId,
|
||||
journal_entry_id: journalEntryId,
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setSelectedMatch((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[transactionId]
|
||||
return next
|
||||
})
|
||||
await fetchAll()
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte matcha transaktion')
|
||||
} finally {
|
||||
setLinkLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlink = async (transactionId: string) => {
|
||||
setUnlinkLoading(transactionId)
|
||||
try {
|
||||
const res = await fetch('/api/reconciliation/bank/unlink', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transaction_id: transactionId }),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
await fetchAll()
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte avmatcha transaktion')
|
||||
} finally {
|
||||
setUnlinkLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar bankavstämning...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !status) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="py-3 text-center text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 inline mr-1" />
|
||||
{error}
|
||||
<Button variant="ghost" size="sm" className="ml-2" onClick={() => setError(null)}>
|
||||
Stäng
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status Card */}
|
||||
{status && (
|
||||
<Card className="border-2">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Avstämning mot <AccountNumber number="1930" /></CardTitle>
|
||||
{status.is_reconciled ? (
|
||||
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Banktransaktioner (summa)</span>
|
||||
<span className="font-mono">{formatAmount(status.bank_transaction_total)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span><AccountNumber number="1930" /> saldo (huvudbok)</span>
|
||||
<span className="font-mono">{formatAmount(status.gl_1930_balance)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
<span className={status.is_reconciled ? 'text-green-600' : 'text-red-600'}>
|
||||
{formatAmount(status.difference)} kr
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
|
||||
<span>Matchade: {status.matched_count}</span>
|
||||
<span>Omatchade transaktioner: {status.unmatched_transaction_count}</span>
|
||||
<span>Omatchade verifikationer: {status.unmatched_gl_line_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Bar */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label>Datum från</Label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Datum till</Label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={fetchAll} variant="outline">
|
||||
Filtrera
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button onClick={handleDryRun} disabled={runLoading} variant="outline">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
|
||||
</Button>
|
||||
{dryRunResults && dryRunResults.length > 0 && (
|
||||
<Button onClick={handleApply} disabled={applyLoading}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{applyLoading ? 'Tillämpar...' : `Tillämpa ${dryRunResults.length} matchningar`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dry Run Preview */}
|
||||
{dryRunResults && dryRunResults.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Förhandsgranskning — {dryRunResults.length} matchningar hittade
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2">Transaktion</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-8 text-center">↔</th>
|
||||
<th className="py-2">Verifikation</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2 w-28">Metod</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dryRunResults.map((m) => (
|
||||
<tr key={m.transaction_id} className="border-b last:border-0">
|
||||
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
|
||||
<td className="py-2">{m.transaction_date}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">↔</td>
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs">{m.voucher_series}{m.voucher_number}</span>
|
||||
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
|
||||
</td>
|
||||
<td className="py-2">{m.entry_date}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{METHOD_LABELS[m.method] || m.method}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{dryRunResults && dryRunResults.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
Inga automatiska matchningar hittades.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Unmatched Transactions */}
|
||||
{unmatchedTx.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Omatchade transaktioner ({unmatchedTx.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-24">Referens</th>
|
||||
<th className="py-2 w-64">Föreslå verifikation</th>
|
||||
<th className="py-2 w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unmatchedTx.map((tx) => (
|
||||
<tr key={tx.id} className="border-b last:border-0">
|
||||
<td className="py-2">{tx.date}</td>
|
||||
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
|
||||
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
|
||||
{formatAmount(tx.amount)} kr
|
||||
</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
value={selectedMatch[tx.id] || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedMatch((prev) => ({ ...prev, [tx.id]: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="">Välj verifikation...</option>
|
||||
{glLines.map((line) => {
|
||||
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<option key={line.line_id} value={line.journal_entry_id}>
|
||||
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatAmount(lineAmount)} kr | {line.entry_description}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!selectedMatch[tx.id] || linkLoading === tx.id}
|
||||
onClick={() => handleManualLink(tx.id)}
|
||||
>
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
{linkLoading === tx.id ? '...' : 'Matcha'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Unmatched GL Lines */}
|
||||
{glLines.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Omatchade verifikationer på <AccountNumber number="1930" /> ({glLines.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-16">Ver.nr</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-24">Typ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{glLines.map((line) => {
|
||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<tr key={line.line_id} className="border-b last:border-0">
|
||||
<td className="py-2 font-mono text-xs">
|
||||
{line.voucher_series}{line.voucher_number}
|
||||
</td>
|
||||
<td className="py-2">{line.entry_date}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">
|
||||
{line.line_description || line.entry_description}
|
||||
</td>
|
||||
<td className={`py-2 text-right font-mono ${amount >= 0 ? 'text-green-600' : ''}`}>
|
||||
{formatAmount(amount)} kr
|
||||
</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recently Matched */}
|
||||
{matchedTx.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowMatched(!showMatched)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showMatched ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-lg">
|
||||
Matchade transaktioner ({matchedTx.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showMatched && (
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-32">Metod</th>
|
||||
<th className="py-2 w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matchedTx.map((tx) => (
|
||||
<tr key={tx.id} className="border-b last:border-0">
|
||||
<td className="py-2">{tx.date}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
|
||||
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
|
||||
{formatAmount(tx.amount)} kr
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{tx.reconciliation_method && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{tx.reconciliation_method && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={unlinkLoading === tx.id}
|
||||
onClick={() => handleUnlink(tx.id)}
|
||||
>
|
||||
<Unlink className="h-3 w-3 mr-1" />
|
||||
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && !loading && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga transaktioner eller verifikationer att stämma av.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,11 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { Supplier, VatTreatment } from '@/types'
|
||||
import type { Supplier } from '@/types'
|
||||
|
||||
interface ReviewLineItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
amount: number
|
||||
account_number: string
|
||||
vat_rate: number
|
||||
}
|
||||
@@ -22,7 +20,6 @@ interface SupplierInvoiceReviewContentProps {
|
||||
deliveryDate?: string
|
||||
currency: string
|
||||
exchangeRate?: string
|
||||
vatTreatment: VatTreatment
|
||||
reverseCharge: boolean
|
||||
paymentReference?: string
|
||||
items: ReviewLineItem[]
|
||||
@@ -35,13 +32,88 @@ function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const VAT_TREATMENT_LABELS: Record<VatTreatment, string> = {
|
||||
standard_25: '25% moms',
|
||||
reduced_12: '12% moms',
|
||||
reduced_6: '6% moms',
|
||||
reverse_charge: 'Omvänd skattskyldighet',
|
||||
export: 'Export (0%)',
|
||||
exempt: 'Momsfritt',
|
||||
interface JournalPreviewLine {
|
||||
account_number: string
|
||||
description: string
|
||||
debit: number
|
||||
credit: number
|
||||
}
|
||||
|
||||
function buildJournalPreview(
|
||||
items: ReviewLineItem[],
|
||||
subtotal: number,
|
||||
totalVat: number,
|
||||
total: number,
|
||||
reverseCharge: boolean,
|
||||
): JournalPreviewLine[] {
|
||||
const lines: JournalPreviewLine[] = []
|
||||
|
||||
// Aggregate expense amounts by account number
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + Math.round(item.amount * 100) / 100)
|
||||
}
|
||||
|
||||
// Debit: Expense accounts
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
account_number: accountNumber,
|
||||
description: accountNumber,
|
||||
debit: Math.round(amount * 100) / 100,
|
||||
credit: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (reverseCharge) {
|
||||
// EU reverse charge: fiktiv moms
|
||||
const vatRate = 0.25
|
||||
const fiktivVat = Math.round(subtotal * vatRate * 100) / 100
|
||||
lines.push({
|
||||
account_number: '2645',
|
||||
description: 'Beraknad ingaende moms',
|
||||
debit: fiktivVat,
|
||||
credit: 0,
|
||||
})
|
||||
lines.push({
|
||||
account_number: '2614',
|
||||
description: 'Utgaende moms omvand',
|
||||
debit: 0,
|
||||
credit: fiktivVat,
|
||||
})
|
||||
// Credit: 2440 at subtotal (no real VAT for reverse charge)
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
description: 'Leverantorsskulder',
|
||||
debit: 0,
|
||||
credit: Math.round(subtotal * 100) / 100,
|
||||
})
|
||||
} else {
|
||||
if (totalVat > 0) {
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
description: 'Ingaende moms',
|
||||
debit: Math.round(totalVat * 100) / 100,
|
||||
credit: 0,
|
||||
})
|
||||
}
|
||||
// Credit: 2440 at total incl. VAT
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
description: 'Leverantorsskulder',
|
||||
debit: 0,
|
||||
credit: Math.round(total * 100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
const ACCOUNT_LABELS: Record<string, string> = {
|
||||
'2440': 'Leverantörsskulder',
|
||||
'2641': 'Ingående moms',
|
||||
'2645': 'Beräknad ingående moms',
|
||||
'2614': 'Utg. moms omvänd skattskyldighet',
|
||||
}
|
||||
|
||||
export function SupplierInvoiceReviewContent({
|
||||
@@ -52,7 +124,6 @@ export function SupplierInvoiceReviewContent({
|
||||
deliveryDate,
|
||||
currency,
|
||||
exchangeRate,
|
||||
vatTreatment,
|
||||
reverseCharge,
|
||||
paymentReference,
|
||||
items,
|
||||
@@ -60,6 +131,10 @@ export function SupplierInvoiceReviewContent({
|
||||
totalVat,
|
||||
total,
|
||||
}: SupplierInvoiceReviewContentProps) {
|
||||
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge)
|
||||
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
|
||||
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Supplier info */}
|
||||
@@ -68,24 +143,19 @@ export function SupplierInvoiceReviewContent({
|
||||
<p className="font-medium text-base">{supplier.name}</p>
|
||||
<p className="text-sm text-muted-foreground">Fakturanr: {invoiceNumber}</p>
|
||||
</div>
|
||||
{reverseCharge && (
|
||||
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
|
||||
Omvänd skattskyldighet
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* VAT + currency badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge className="text-sm px-3 py-1">
|
||||
{VAT_TREATMENT_LABELS[vatTreatment]}
|
||||
</Badge>
|
||||
{currency !== 'SEK' && (
|
||||
<Badge variant="outline" className="text-sm px-3 py-1">
|
||||
{currency}
|
||||
{exchangeRate && ` (kurs ${exchangeRate})`}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{reverseCharge && (
|
||||
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
|
||||
Omvänd skattskyldighet
|
||||
</Badge>
|
||||
)}
|
||||
{currency !== 'SEK' && (
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{currency}
|
||||
{exchangeRate && ` (kurs ${exchangeRate})`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
@@ -110,27 +180,25 @@ export function SupplierInvoiceReviewContent({
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-16 text-right">Antal</th>
|
||||
<th className="py-2 w-24 text-right">À-pris</th>
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<th className="py-2 w-16 text-right">Moms</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-16 text-right">Moms%</th>
|
||||
<th className="py-2 w-24 text-right">Moms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => {
|
||||
const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 100) / 100
|
||||
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
|
||||
return (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="py-2">{item.description}</td>
|
||||
<td className="py-2 text-right">{item.quantity} {item.unit}</td>
|
||||
<td className="py-2 text-right">{formatAmount(item.unit_price)}</td>
|
||||
<td className="py-2">
|
||||
<AccountNumber number={item.account_number} size="sm" />
|
||||
</td>
|
||||
<td className="py-2">{item.description}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
|
||||
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
|
||||
<td className="py-2 text-right">{formatAmount(lineTotal)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
@@ -160,6 +228,46 @@ export function SupplierInvoiceReviewContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Verifikation preview */}
|
||||
<div className="bg-muted/50 border rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-semibold text-muted-foreground">Verifikation som bokförs</p>
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground text-xs">
|
||||
<th className="pb-1 w-16">Konto</th>
|
||||
<th className="pb-1">Beskrivning</th>
|
||||
<th className="pb-1 w-24 text-right">Debet</th>
|
||||
<th className="pb-1 w-24 text-right">Kredit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{journalLines.map((line, index) => (
|
||||
<tr key={index} className="border-b border-dashed border-muted-foreground/20 last:border-0">
|
||||
<td className="py-1">
|
||||
<AccountNumber number={line.account_number} size="sm" />
|
||||
</td>
|
||||
<td className="py-1 text-xs">
|
||||
{ACCOUNT_LABELS[line.account_number] || line.description}
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-semibold">
|
||||
<td className="pt-1" colSpan={2}>SUMMA</td>
|
||||
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
|
||||
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Payment reference */}
|
||||
{paymentReference && (
|
||||
<div className="border-t pt-3 text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -49,12 +48,12 @@ export default function BatchCategorySelector({
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isProcessing
|
||||
? `Kategoriserar ${progress.done}/${progress.total}...`
|
||||
: `Kategorisera ${selectedCount} transaktioner`}
|
||||
? `Bokför ${progress.done}/${progress.total}...`
|
||||
: `Bokför ${selectedCount} transaktioner`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isProcessing
|
||||
? 'Vänta medan transaktionerna kategoriseras'
|
||||
? 'Vänta medan transaktionerna bokförs'
|
||||
: 'Välj en kategori som ska tillämpas på alla valda transaktioner'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function SwipeCategorizationView({
|
||||
if (success) {
|
||||
moveToNext()
|
||||
} else {
|
||||
setError('Kunde inte kategorisera. Tryck "Hoppa över" för att gå vidare.')
|
||||
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
|
||||
}
|
||||
} catch {
|
||||
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
|
||||
@@ -140,7 +140,7 @@ export default function SwipeCategorizationView({
|
||||
setShowCategorySelect(false)
|
||||
moveToNext()
|
||||
} else {
|
||||
setError('Kunde inte kategorisera. Tryck "Hoppa över" för att gå vidare.')
|
||||
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
|
||||
}
|
||||
} catch {
|
||||
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
|
||||
@@ -186,7 +186,7 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Klart!</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Alla transaktioner är nu kategoriserade
|
||||
Alla transaktioner är nu bokförda
|
||||
</p>
|
||||
<Button onClick={onClose} className="mt-6">
|
||||
Tillbaka till transaktioner
|
||||
@@ -276,7 +276,7 @@ export default function SwipeCategorizationView({
|
||||
<span>Hoppa över</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span>Kategorisera</span>
|
||||
<span>Bokför</span>
|
||||
<Building className="h-4 w-4" />
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -457,7 +457,7 @@ export default function SwipeCategorizationView({
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Kategorisera
|
||||
Bokför
|
||||
</Button>
|
||||
|
||||
{/* Skip button - always visible */}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ConfirmationDialogProps {
|
||||
title: string
|
||||
warningText?: string
|
||||
confirmLabel?: string
|
||||
extraActions?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ export function ConfirmationDialog({
|
||||
title,
|
||||
warningText = 'En verifikation skapas och kan inte ändras efteråt.',
|
||||
confirmLabel = 'Bekräfta & skapa',
|
||||
extraActions,
|
||||
children,
|
||||
}: ConfirmationDialogProps) {
|
||||
return (
|
||||
@@ -66,6 +68,7 @@ export function ConfirmationDialog({
|
||||
>
|
||||
Tillbaka
|
||||
</Button>
|
||||
{extraActions}
|
||||
<Button onClick={onConfirm} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function EmptyReceipts() {
|
||||
<EmptyState
|
||||
icon={Camera}
|
||||
title="Inga kvitton"
|
||||
description="Ta en bild på ett kvitto för automatisk avläsning och kategorisering. Vi sköter resten!"
|
||||
description="Ta en bild på ett kvitto för automatisk avläsning och bokföring. Vi sköter resten!"
|
||||
actionLabel="Skanna kvitto"
|
||||
actionHref="/receipts/scan"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user