Files
accounted/app/(dashboard)/transactions/page.tsx
T
Jakob WennbergandClaude Fable 5 ea236cbcdf fix(reconciliation): Bankavstämning phase 0 — correctness + feedback batch (+ nav IA regrouping) (#879)
* feat(nav): interaction-mode sidebar grouping — Arbeta/Analys/Data/Skatt & bokslut

Nav IA redesign phase 0 (dev_docs/nav_ia_redesign.md): same routes,
regrouped by what the user is doing. CLAUDE.md restructured around Hard
Rules (doc references updated); pending-page explainer removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): correctness + feedback batch for Bankavstämning (phase 0)

Engine: fetchAllRows pagination on status/run/RPC fetches (silent 1000-row
cap corrupted totals), optimistic-lock guards on manualLink + apply,
unlink audit rows attributed to the acting user (was: company UUID),
selected_matches partial apply intersected with a fresh match run.

View: silent in-place refresh instead of a full-page skeleton per action,
checkbox-gated apply with confidence badges (fuzzy unticked) in chunks of
500, honest result toasts, dry-run errors surfaced, ranked per-row picker
candidates pinned to the applied date window, currency-correct amounts
(bank side in account currency, GL side SEK), voucher links, translated
source types, colored differens, dirty-date-filter guard.

Discovery: year-end preflight 404 href fixed (/reconciliation/bank never
existed), ⌘K palette entry, real links from the transactions page.

v1: status registry schema now matches the actual ReconciliationStatus
payload, errors documented as a count, false ~0.85-threshold pitfall
replaced, route test mocks the real shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:21:38 +02:00

2687 lines
107 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import Link from 'next/link'
import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { DataList, DataListHeader, DataListEmpty } from '@/components/ui/data-list'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu'
import { ChevronDown, EyeOff, Layers, Search, Trash2, X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
import BankSyncSinceLastVisit from '@/components/transactions/BankSyncSinceLastVisit'
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard'
import { SkattekontoMatchDialog } from '@/components/skattekonto/SkattekontoMatchDialog'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import { MatchVoucherDialog } from '@/components/transactions/MatchVoucherDialog'
import InvoicePicker from '@/components/transactions/InvoicePicker'
import SupplierInvoicePicker from '@/components/transactions/SupplierInvoicePicker'
import MatchAllocationDialog from '@/components/transactions/MatchAllocationDialog'
import BulkBookDialog from '@/components/transactions/BulkBookDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import TransactionAttachDocumentDialog from '@/components/transactions/TransactionAttachDocumentDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog'
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
import TemplatePicker from '@/components/transactions/TemplatePicker'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status'
type InvoiceWithCustomer = Invoice & { customer?: Customer }
type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
function buildInvoiceMap(rows: InvoiceWithCustomer[] | null): Record<string, InvoiceWithCustomer> {
if (!rows) return {}
return rows.reduce<Record<string, InvoiceWithCustomer>>((acc, inv) => {
acc[inv.id] = inv
return acc
}, {})
}
function buildSupplierInvoiceMap(
rows: SupplierInvoiceWithSupplier[] | null,
): Record<string, SupplierInvoiceWithSupplier> {
if (!rows) return {}
return rows.reduce<Record<string, SupplierInvoiceWithSupplier>>((acc, inv) => {
acc[inv.id] = inv
return acc
}, {})
}
interface QuickReviewState {
transaction: TransactionWithInvoice
category: TransactionCategory
label: string
template: BookingTemplate | null
templateId: string | undefined
linePattern: LinePatternEntry[] | null
}
export default function TransactionsPage() {
const { company } = useCompany()
const companyId = company?.id ?? null
const t = useTranslations('transactions')
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [mode, setMode] = useState<ViewMode>('inbox')
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [templateSuggestions, setTemplateSuggestions] = useState<Record<string, SuggestedTemplate[]>>({})
const [processingId, setProcessingId] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
// Batch mode
const [isBatchMode, setIsBatchMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchSelector, setShowBatchSelector] = useState(false)
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
// Invoice match dialog
const [matchDialogOpen, setMatchDialogOpen] = useState(false)
const [selectedTransaction, setSelectedTransaction] = useState<TransactionWithInvoice | null>(null)
const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
// Booking dialog (journal entry form)
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
const [bookingDialogTemplate, setBookingDialogTemplate] = useState<BookingTemplateLibrary | null>(null)
// Attach-underlag dialog (tx→doc mirror of the Documents view's matcher)
const [attachDocTx, setAttachDocTx] = useState<TransactionWithInvoice | null>(null)
// Underlag status per booked journal_entry_id — drives the per-row
// "Underlag"/"Underlag saknas" badges in history view.
const [jeUnderlagStatus, setJeUnderlagStatus] = useState<Record<string, JeUnderlagStatus>>({})
// JE ids already requested (in-flight or done) so the enrichment effect
// never refetches on unrelated transactions-state changes.
const requestedJeIdsRef = useRef<{ companyId: string | null; ids: Set<string> }>({
companyId: null,
ids: new Set(),
})
// Template picker dialog
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [templatePickerTransaction, setTemplatePickerTransaction] = useState<TransactionWithInvoice | null>(null)
// Invoice picker dialog (manual match)
const [invoicePickerOpen, setInvoicePickerOpen] = useState(false)
const [invoicePickerTransaction, setInvoicePickerTransaction] = useState<TransactionWithInvoice | null>(null)
const [supplierInvoicePickerOpen, setSupplierInvoicePickerOpen] = useState(false)
const [supplierInvoicePickerTransaction, setSupplierInvoicePickerTransaction] = useState<TransactionWithInvoice | null>(null)
const [splitMatchOpen, setSplitMatchOpen] = useState(false)
const [splitMatchTransaction, setSplitMatchTransaction] = useState<TransactionWithInvoice | null>(null)
// "Matcha mot befintlig verifikation" — link a bank tx to an already-booked
// voucher (salary, Fortnox import, manual entry) with no new bokföring.
const [matchVoucherTx, setMatchVoucherTx] = useState<TransactionWithInvoice | null>(null)
const [bulkBookOpen, setBulkBookOpen] = useState(false)
const [isMatchingSupplierFromPicker, setIsMatchingSupplierFromPicker] = useState(false)
const [isMatchingFromPicker, setIsMatchingFromPicker] = useState(false)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
const [quickReview, setQuickReview] = useState<QuickReviewState | null>(null)
// Prong B: prompt to match against an open supplier invoice instead of
// categorizing direct to 2440. Triggered by a 409 TX_CATEGORIZE_SUGGEST_SI_MATCH.
const [siMatchSuggestion, setSiMatchSuggestion] = useState<{
transactionId: string
retry: () => Promise<string | null>
candidates: Array<{
supplier_invoice_id: string
invoice_number: string
invoice_date: string
remaining_amount: number
currency: string
supplier_name: string | null
}>
} | null>(null)
const [siMatchProcessing, setSiMatchProcessing] = useState(false)
// Prong B (customer side): prompt to match against an unpaid customer
// invoice instead of categorizing direct to 1510 on an inbound bank tx.
// Triggered by a 409 TX_CATEGORIZE_SUGGEST_CI_MATCH.
const [ciMatchSuggestion, setCiMatchSuggestion] = useState<{
transactionId: string
retry: () => Promise<string | null>
candidates: Array<{
invoice_id: string
invoice_number: string | null
invoice_date: string
remaining_amount: number
currency: string
customer_name: string | null
match_reason: 'ocr_exact' | 'name_amount_fuzzy'
}>
} | null>(null)
const [ciMatchProcessing, setCiMatchProcessing] = useState(false)
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the
// server found this affärshändelse already booked — either another booked
// transaction sharing this one's date+amount+bank account, OR an unlinked
// voucher that already books the amount on the bank account (a paid invoice,
// a salary payout). Surface the existing verifikat and let the user book
// anyway — genuinely repeated same-day payments (e.g. identical Swish
// transfers) are legitimate. "Bokför ändå" retries with force bound to the
// reviewed candidate via expected_duplicate_journal_entry_id (present on both
// candidate kinds), which the server re-detects so a stale id can't wave it.
const [duplicateWarning, setDuplicateWarning] = useState<{
transactionId: string
retry: () => Promise<string | null>
candidate: {
transaction_id: string | null
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
amount: number
}
} | null>(null)
const [duplicateProcessing, setDuplicateProcessing] = useState(false)
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
// Pagination
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
// True uncategorized count from DB (not limited by pagination)
const [totalUncategorizedCount, setTotalUncategorizedCount] = useState<number | null>(null)
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
// Skattekonto rows (unmatched, status='booked'). Loaded if the
// Skatteverket extension is enabled and connected. 503/401 → silently
// hidden (extension disabled or user not connected).
const [skvRows, setSkvRows] = useState<SkattekontoTransactionWithSuggestion[]>([])
const [skvProcessingId, setSkvProcessingId] = useState<string | null>(null)
const [skvMatchTarget, setSkvMatchTarget] = useState<StoredSkattekontoTransaction | null>(
null,
)
// Source filter for the merged inbox. Defaults to 'all' so users see
// both sources unless they want to narrow down.
const [sourceFilter, setSourceFilter] = useState<'all' | 'bank' | 'skatteverket'>('all')
const { toast } = useToast()
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
// Bank transaction whose title is being edited (null = dialog closed).
const [editTitleTarget, setEditTitleTarget] = useState<TransactionWithInvoice | null>(null)
const supabase = useRealtimeSupabase()
const searchParams = useSearchParams()
const highlightId = searchParams.get('highlight')
// Tracks the last highlight target we acted on so re-renders don't re-trigger
// the auto-open every time the user closes the categorize panel.
const handledHighlightRef = useRef<string | null>(null)
const refreshTransactionsInFlightRef = useRef(false)
const refreshTransactionsQueuedRef = useRef(false)
// Computed lists
const uncategorizedTransactions = transactions
.filter((t) => t.is_business === null && !t.is_ignored && !exitingIds.has(t.id))
.sort((a, b) => {
const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0
const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
})
// Merged inbox: bank tx + SKV rows interleaved by date. Source filter
// narrows to one side. SKV rows always go after bank rows on the same
// date — bank tx tend to have invoice-match suggestions and we'd rather
// surface those first.
type InboxItem =
| { source: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
const skvUnmatched = skvRows.filter(r => !r.journal_entry_id)
const bankToSkvHints = findBankSkvCounterparts({
bankRows: uncategorizedTransactions.map(t => ({ id: t.id, date: t.date, amount: t.amount })),
skvRows: skvUnmatched,
})
const inboxItems: InboxItem[] = (() => {
const items: InboxItem[] = []
const query = searchTerm.trim().toLowerCase()
if (sourceFilter !== 'skatteverket') {
for (const tx of uncategorizedTransactions) {
if (
query &&
!tx.description?.toLowerCase().includes(query) &&
!tx.date.includes(query) &&
!String(tx.amount).includes(query)
) {
continue
}
items.push({ source: 'bank', date: tx.date, data: tx })
}
}
if (sourceFilter !== 'bank') {
// Inbox only shows SKV rows that need action (no verifikat yet).
for (const r of skvRows) {
if (r.journal_entry_id) continue
if (exitingIds.has(r.id)) continue
if (
query &&
!r.transaktionstext?.toLowerCase().includes(query) &&
!r.transaktionsdatum.includes(query) &&
!String(r.belopp_skatteverket).includes(query)
) {
continue
}
items.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r })
}
}
return items.sort((a, b) => {
if (a.date !== b.date) return b.date.localeCompare(a.date)
// Same date → bank first so invoice-match cards lead.
if (a.source !== b.source) return a.source === 'bank' ? -1 : 1
return 0
})
})()
const transactionsWithMatches = transactions.filter(
(t) =>
(t.potential_invoice && !t.invoice_id) ||
(t.potential_supplier_invoice && !t.supplier_invoice_id),
)
const PAGE_SIZE = 200
const loadSkvRows = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner')
if (!res.ok) {
setSkvRows([])
return
}
const json = await res.json()
const booked = (json.data?.booked ?? []) as SkattekontoTransactionWithSuggestion[]
// Keep all booked SKV rows in state — inbox view filters to obokförda
// (journal_entry_id null), history view shows all of them (matched
// and unmatched) interleaved with bank tx by date.
setSkvRows(booked)
} catch {
setSkvRows([])
}
}, [])
const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => {
if (!companyId) return
if (showLoading) setIsLoading(true)
try {
const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([
supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.order('date', { ascending: false })
.limit(PAGE_SIZE),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('is_business', null)
// Same predicate as lib/worklist countUnbookedTransactions — ignored
// rows are handled, not pending.
.eq('is_ignored', false),
])
if (txError) {
toast({ title: t('load_failed_title'), description: t('load_failed_description'), variant: 'destructive' })
return
}
const rows = txData || []
const potentialInvoiceIds = rows
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const potentialSupplierInvoiceIds = rows
.filter((t) => t.potential_supplier_invoice_id)
.map((t) => t.potential_supplier_invoice_id)
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
potentialSupplierInvoiceIds.length > 0
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
: Promise.resolve({ data: null }),
])
const invoiceMap = buildInvoiceMap(invoiceResult.data)
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
const transactionsWithInvoices: TransactionWithInvoice[] = rows.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
}))
setTransactions(transactionsWithInvoices)
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore(rows.length >= PAGE_SIZE)
// Fire-and-forget: load SKV rows in parallel with the rest of the
// page. We don't block on this — if the extension is disabled or the
// user isn't connected the response is 503/401 and we just leave the
// SKV section empty.
if (includeSkvRows) {
void loadSkvRows()
}
} finally {
if (showLoading) setIsLoading(false)
}
}, [companyId, loadSkvRows, supabase, t, toast])
const refreshTransactions = useCallback(async () => {
if (!companyId) return
if (refreshTransactionsInFlightRef.current) {
refreshTransactionsQueuedRef.current = true
return
}
refreshTransactionsInFlightRef.current = true
try {
do {
refreshTransactionsQueuedRef.current = false
await fetchTransactions(false, false)
} while (refreshTransactionsQueuedRef.current)
} finally {
refreshTransactionsInFlightRef.current = false
refreshTransactionsQueuedRef.current = false
}
}, [companyId, fetchTransactions])
async function loadMoreTransactions() {
if (!companyId) return
setIsLoadingMore(true)
const offset = transactions.length
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.order('date', { ascending: false })
.range(offset, offset + PAGE_SIZE - 1)
if (txError || !txData) {
setIsLoadingMore(false)
return
}
setHasMore(txData.length >= PAGE_SIZE)
const potentialInvoiceIds = txData
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const potentialSupplierInvoiceIds = txData
.filter((t) => t.potential_supplier_invoice_id)
.map((t) => t.potential_supplier_invoice_id)
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
potentialSupplierInvoiceIds.length > 0
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
: Promise.resolve({ data: null }),
])
const invoiceMap = buildInvoiceMap(invoiceResult.data)
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
}))
setTransactions((prev) => [...prev, ...newTransactions])
setIsLoadingMore(false)
}
// Underlag-status enrichment for booked rows. Three RLS-scoped reads per
// 150-id chunk (PostgREST .in() URL-length convention, see
// lib/worklist/categories.ts): the JEs' source types, which JEs have a
// current-version document, and which are exempted via
// journal_entry_no_doc_required. Incremental — only fetches JE ids not yet
// requested, so loadMoreTransactions pages are covered without refetching.
// Soft-fails to "no badges" on error.
useEffect(() => {
if (!companyId) return
if (requestedJeIdsRef.current.companyId !== companyId) {
requestedJeIdsRef.current = { companyId, ids: new Set() }
setJeUnderlagStatus({})
}
const requested = requestedJeIdsRef.current.ids
const newIds = Array.from(
new Set(
transactions
.map((tx) => tx.journal_entry_id)
.filter((id): id is string => !!id && !requested.has(id)),
),
)
if (newIds.length === 0) return
newIds.forEach((id) => requested.add(id))
;(async () => {
const IN_CLAUSE_CHUNK = 150
const merged: Record<string, JeUnderlagStatus> = {}
for (let i = 0; i < newIds.length; i += IN_CLAUSE_CHUNK) {
const chunk = newIds.slice(i, i + IN_CLAUSE_CHUNK)
const [entriesRes, docsRes, exemptRes] = await Promise.all([
supabase
.from('journal_entries')
.select('id, source_type')
// Same posted-only scope as countVerifikatMissingDocument:
// reversed/corrected entries fall out of the result set and the
// row renders no badge — a storno'd verifikation must never grow
// an "Underlag saknas" attach affordance.
.eq('status', 'posted')
.in('id', chunk)
.eq('company_id', companyId),
supabase
.from('document_attachments')
.select('journal_entry_id')
.in('journal_entry_id', chunk)
.eq('company_id', companyId)
.eq('is_current_version', true),
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
.in('journal_entry_id', chunk)
.eq('company_id', companyId),
])
// Soft-fail: keep the chunks that already succeeded.
if (entriesRes.error || docsRes.error || exemptRes.error) break
const jeIdsWithDocs = new Set(
(docsRes.data ?? []).map((d) => d.journal_entry_id as string),
)
const exemptIds = new Set(
(exemptRes.data ?? []).map((e) => e.journal_entry_id as string),
)
Object.assign(
merged,
computeJeUnderlagStatus(entriesRes.data ?? [], jeIdsWithDocs, exemptIds),
)
}
// The merge is an idempotent keyed write, so it stays valid across
// unrelated transactions-state changes (booking a row, deletes,
// load-more) — only a company switch invalidates it. No cleanup-based
// cancellation: that would orphan ids already marked as requested.
if (requestedJeIdsRef.current.companyId === companyId && Object.keys(merged).length > 0) {
setJeUnderlagStatus((prev) => ({ ...prev, ...merged }))
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions, companyId])
async function fetchCategorySuggestions(txIds: string[]) {
if (txIds.length === 0) return
try {
const response = await fetch('/api/transactions/suggest-categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_ids: txIds }),
})
if (!response.ok) throw new Error('Failed to fetch suggestions')
const data = await response.json()
if (data.template_suggestions) {
setTemplateSuggestions(data.template_suggestions)
}
} catch {
// Non-critical
}
}
// Fetch transactions and entity type in parallel on mount, then suggestions
useEffect(() => {
let cancelled = false
async function loadAll() {
// Fetch transactions and entity type in parallel
const [, entityRes] = await Promise.all([
fetchTransactions(true, true),
fetch('/api/settings').then(r => r.json()).catch(() => null),
])
if (cancelled) return
if (entityRes?.data?.entity_type) {
setEntityType(entityRes.data.entity_type)
}
}
loadAll()
return () => { cancelled = true }
}, [fetchTransactions])
useEffect(() => {
if (!companyId) return
let cancelled = false
const refreshFromRealtime = async () => {
if (cancelled) return
await refreshTransactions()
}
const channel = supabase
.channel(`transactions:list:${companyId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'transactions',
filter: `company_id=eq.${companyId}`,
},
() => {
void refreshFromRealtime()
},
)
.subscribe()
return () => {
cancelled = true
void supabase.removeChannel(channel)
}
}, [companyId, refreshTransactions, supabase])
// Scroll the targeted row into view when arriving via
// /transactions?highlight=<id>. Callers are inbox "Öppna transaktionen",
// payment-booking dialog, and supplier-invoice cross-link — all "go look
// at this row", not "start booking". The legacy auto-open-template-picker
// behavior was removed in v5: booking happens in the inbox workspace now.
// Runs once per distinct highlight id so closing/scrolling away doesn't
// re-trigger it.
useEffect(() => {
if (!highlightId) return
if (handledHighlightRef.current === highlightId) return
if (transactions.length === 0) return
const tx = transactions.find((t) => t.id === highlightId)
if (!tx) return
handledHighlightRef.current = highlightId
// Defer the scroll until React has committed the list to the DOM.
// Without rAF the data-tx-id node may not exist yet when this fires
// immediately after fetchTransactions resolves.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = document.querySelector(`[data-tx-id="${tx.id}"]`)
if (el && 'scrollIntoView' in el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
})
}, [highlightId, transactions])
// Auto-fetch suggestions when transactions load
useEffect(() => {
const uncatIds = transactions
.filter((t) => t.is_business === null)
.map((t) => t.id)
.slice(0, 50)
if (uncatIds.length > 0) {
fetchCategorySuggestions(uncatIds)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions.length])
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
return runCategorize({ id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch: false })
}
async function runCategorize(args: {
id: string
isBusiness: boolean
category?: TransactionCategory
vatTreatment?: VatTreatment
accountOverride?: string
templateId?: string
inboxItemId?: string
confirmNoMatch: boolean
// Set after the user confirms the booking-time duplicate warning. force
// bypasses the guard; the bypass is bound to the reviewed candidate's
// voucher (journal_entry_id), present on both a sibling-transaction and a
// ledger-only voucher candidate.
force?: boolean
expectedDuplicateJournalEntryId?: string
}): Promise<string | null> {
const { id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch, force, expectedDuplicateJournalEntryId } = args
try {
setProcessingId(id)
const response = await fetch(`/api/transactions/${id}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_business: isBusiness,
category,
vat_treatment: vatTreatment,
account_override: accountOverride,
template_id: templateId,
inbox_item_id: inboxItemId,
...(confirmNoMatch ? { confirm_no_match: true } : {}),
...(force && expectedDuplicateJournalEntryId
? { force: true, expected_duplicate_journal_entry_id: expectedDuplicateJournalEntryId }
: {}),
}),
})
const result = await response.json()
if (!response.ok) {
if (
result?.error?.code === 'TX_CATEGORIZE_SUGGEST_SI_MATCH' &&
Array.isArray(result.error.details?.candidates)
) {
// Prong B: invite the user to match the open supplier invoice
// instead of booking a plain 2440 categorization that would later
// create a duplicate when they hit "Markera som betald".
setSiMatchSuggestion({
transactionId: id,
retry: () => runCategorize({ ...args, confirmNoMatch: true }),
candidates: result.error.details.candidates,
})
setProcessingId(null)
return null
}
if (
result?.error?.code === 'TX_CATEGORIZE_SUGGEST_CI_MATCH' &&
Array.isArray(result.error.details?.candidates)
) {
// Prong B (customer side): invite the user to match the unpaid
// customer invoice instead of booking a plain 1510 categorization
// that would later create a duplicate.
setCiMatchSuggestion({
transactionId: id,
retry: () => runCategorize({ ...args, confirmNoMatch: true }),
candidates: result.error.details.candidates,
})
setProcessingId(null)
return null
}
if (result?.error?.code === 'TX_CATEGORIZE_INVALID_ACCOUNT') {
// The user picked a library template (or typed an account
// override) whose account isn't in this company's kontoplan.
// Mirror the ACCOUNTS_NOT_IN_CHART flow with a one-click
// "Aktivera och bokför" — pull the BAS name if known so the
// toast carries real context.
// Validate the BAS account number is a plain 4-digit string before
// embedding it in any fetch URL/body — the value comes from the
// server error envelope but defense-in-depth.
const rawAccountNumber: unknown = result.error.details?.accountNumber
const accountNumber: string | undefined =
typeof rawAccountNumber === 'string' && /^\d{4}$/.test(rawAccountNumber)
? rawAccountNumber
: undefined
let displayName = accountNumber ?? ''
if (accountNumber) {
try {
const lookupRes = await fetch(`/api/bookkeeping/accounts/bas-lookup?numbers=${encodeURIComponent(accountNumber)}`)
if (lookupRes.ok) {
const lookup = await lookupRes.json() as { data?: Array<{ account_number: string; account_name: string | null; known?: boolean }> }
const hit = lookup.data?.find((r) => r.account_number === accountNumber)
if (hit?.account_name) displayName = `${accountNumber} — ${hit.account_name}`
}
} catch { /* fall through to the plain number */ }
}
let invalidAccountActivateInFlight = false
toast({
title: 'Kontot finns inte i din kontoplan',
description: accountNumber
? `Kontot ${displayName} är inte aktiverat.`
: 'Kontot är inte aktiverat.',
variant: 'destructive',
action: accountNumber ? (
<ToastAction altText="Aktivera och bokför" onClick={async () => {
if (invalidAccountActivateInFlight) return
invalidAccountActivateInFlight = true
try {
const activateRes = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!activateRes.ok) {
const errBody = await activateRes.json().catch(() => null)
toast({
title: 'Kunde inte aktivera kontot',
description: getErrorMessage(errBody, { statusCode: activateRes.status }),
variant: 'destructive',
})
return
}
const activateBody = await activateRes.json()
if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) {
toast({
title: 'Kontot finns inte i BAS-planen',
description: `Lägg till ${accountNumber} manuellt i Kontoplan.`,
variant: 'destructive',
})
return
}
await runCategorize(args)
} finally {
invalidAccountActivateInFlight = false
}
}}>
Aktivera och bokför
</ToastAction>
) : undefined,
})
setProcessingId(null)
return null
}
if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') {
// The mapped template/category references one or more accounts
// that aren't active in this company's kontoplan. Without an
// inline action the user has to navigate to settings, activate
// each account, and come back — surface a one-click "Aktivera
// och bokför" instead.
const accountNumbers: string[] =
(Array.isArray(result.error.account_numbers) && result.error.account_numbers) ||
(Array.isArray(result.error.details?.account_numbers) && result.error.details.account_numbers) ||
[]
// Synchronous in-flight flag per toast closure: a double-click
// would otherwise fire two activate+categorize pairs, where the
// second categorize races the first's verifikation insert.
let activateInFlight = false
toast({
title: 'Kontot finns inte i din kontoplan',
description: `Bokföringsmallen kräver att följande konton aktiveras: ${accountNumbers.join(', ')}.`,
variant: 'destructive',
action: accountNumbers.length > 0 ? (
<ToastAction altText="Aktivera och bokför" onClick={async () => {
if (activateInFlight) return
activateInFlight = true
try {
const activateRes = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: accountNumbers }),
})
if (!activateRes.ok) {
const errBody = await activateRes.json().catch(() => null)
toast({
title: 'Kunde inte aktivera konton',
description: getErrorMessage(errBody, { statusCode: activateRes.status }),
variant: 'destructive',
})
return
}
const activateBody = await activateRes.json()
// unknown[] = numbers not in BAS reference at all. Those
// can't be auto-created; tell the user to add them manually.
if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) {
toast({
title: 'Kunde inte hitta alla konton',
description: `Lägg till ${activateBody.unknown.join(', ')} manuellt i Kontoplan.`,
variant: 'destructive',
})
return
}
await runCategorize(args)
} finally {
activateInFlight = false
}
}}>
Aktivera och bokför
</ToastAction>
) : undefined,
})
setProcessingId(null)
return null
}
if (
result?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' &&
result.error.details?.candidate
) {
// Booking-time duplicate guard fired. Don't dead-end on a toast that
// merely says "book anyway" with no way to do so — open a dialog with
// the already-booked sibling and let the user confirm. "Bokför ändå"
// re-runs with force bound to this candidate (server re-detects it).
const candidate = result.error.details.candidate as {
transaction_id: string | null
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
amount: number
}
setDuplicateWarning({
transactionId: id,
retry: () =>
runCategorize({
...args,
force: true,
expectedDuplicateJournalEntryId: candidate.journal_entry_id,
}),
candidate,
})
setProcessingId(null)
return null
}
toast({
title: 'Kategorisering misslyckades',
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
variant: 'destructive',
})
setProcessingId(null)
return null
}
// Mark as exiting for animation, then update state
setExitingIds((prev) => new Set(prev).add(id))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
if (result.journal_entry_created) {
toast({
title: 'Bokförd',
action: (
<ToastAction altText="Ångra kategorisering" onClick={async () => {
try {
const undoRes = await fetch(`/api/transactions/${id}/uncategorize`, { method: 'POST' })
if (undoRes.ok) {
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: null, category: null as unknown as TransactionCategory, journal_entry_id: null }
: t
)
)
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
toast({ title: t('undone_title'), description: t('undone_description') })
} else {
const errData = await undoRes.json()
toast({
title: 'Kunde inte ångra',
description: getErrorMessage(errData, { context: 'transaction', statusCode: undoRes.status }),
variant: 'destructive',
})
}
} catch {
toast({ title: t('undo_failed_title'), description: t('undo_failed_description'), variant: 'destructive' })
}
}}>
Ångra
</ToastAction>
),
})
} else if (result.journal_entry_error) {
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
} else {
toast({ title: t('partially_booked_title'), description: t('partially_booked_description') })
}
// Update transaction in state after a brief delay for animation
setExitingIds((prev) => new Set(prev).add(id))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: isBusiness, category: result.category, journal_entry_id: result.journal_entry_id }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
setProcessingId(null)
}, 350)
return result.journal_entry_id || null
} catch {
toast({ title: t('booking_failed_title'), description: t('booking_failed_description'), variant: 'destructive' })
setProcessingId(null)
return null
}
}
async function handleMatchSuggestedInvoice(transactionId: string, invoiceId: string) {
setCiMatchProcessing(true)
try {
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: invoiceId }),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Matchning misslyckades',
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
variant: 'destructive',
})
setCiMatchProcessing(false)
return
}
toast({ title: t('customer_invoice_matched_title'), description: t('customer_invoice_matched_description') })
setCiMatchSuggestion(null)
setExitingIds((prev) => new Set(prev).add(transactionId))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? {
...t,
invoice_id: invoiceId,
is_business: true,
journal_entry_id: result.journal_entry_id ?? t.journal_entry_id,
}
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
} catch {
toast({ title: t('match_failed_title'), description: t('match_failed_description_retry'), variant: 'destructive' })
} finally {
setCiMatchProcessing(false)
}
}
async function handleMatchSuggestedSupplierInvoice(transactionId: string, supplierInvoiceId: string) {
setSiMatchProcessing(true)
try {
const response = await fetch(`/api/transactions/${transactionId}/match-supplier-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_invoice_id: supplierInvoiceId }),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Matchning misslyckades',
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
variant: 'destructive',
})
setSiMatchProcessing(false)
return
}
toast({ title: t('supplier_invoice_matched_title'), description: t('supplier_invoice_matched_description') })
setSiMatchSuggestion(null)
setExitingIds((prev) => new Set(prev).add(transactionId))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? {
...t,
supplier_invoice_id: supplierInvoiceId,
is_business: true,
journal_entry_id: result.journal_entry_id ?? t.journal_entry_id,
}
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
} catch {
toast({ title: t('match_failed_title'), description: t('match_failed_description_retry'), variant: 'destructive' })
} finally {
setSiMatchProcessing(false)
}
}
async function handleIgnoreTransaction(tx: TransactionWithInvoice) {
// Mirrors BankReconciliationView's ignore flow: Ignorera is fully
// reversible, but the row vanishes immediately — confirmation before the
// write plus an Ångra toast gives two recovery affordances. The
// "Ignorerade transaktioner" card on Rapporter → Bankavstämning is the
// standing third.
const ok = await confirm({
title: 'Ignorera transaktionen?',
description: `${tx.description} — ${formatCurrency(tx.amount, tx.currency)} (${formatDate(tx.date)}) försvinner från listan utan att bokföras. Använd bara för poster som inte är affärshändelser, t.ex. dubbletter eller överföringar mellan egna konton — riktiga köp och betalningar ska bokföras. Du kan återställa den under Bankavstämning när som helst.`,
confirmLabel: 'Ignorera',
cancelLabel: 'Avbryt',
variant: 'warning',
})
if (!ok) return
setTemplatePickerOpen(false)
try {
const res = await fetch(`/api/transactions/${tx.id}/ignore`, { method: 'POST' })
const result = await res.json()
if (!res.ok || result.error) {
toast({
title: 'Kunde inte ignorera transaktionen',
description: typeof result.error === 'string' ? result.error : undefined,
variant: 'destructive',
})
return
}
setExitingIds((prev) => new Set(prev).add(tx.id))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) => (t.id === tx.id ? { ...t, is_ignored: true } : t))
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(tx.id)
return next
})
}, 350)
toast({
title: 'Transaktionen ignorerad',
description: `${tx.description} — ${formatCurrency(tx.amount, tx.currency)}`,
action: (
<ToastAction altText="Ångra ignorera" onClick={() => void handleUnignoreTransaction(tx.id)}>
Ångra
</ToastAction>
),
})
} catch {
toast({ title: 'Kunde inte ignorera transaktionen', variant: 'destructive' })
}
}
async function handleUnignoreTransaction(transactionId: string) {
try {
const res = await fetch(`/api/transactions/${transactionId}/ignore`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok || result.error) {
toast({ title: 'Kunde inte återställa transaktionen', variant: 'destructive' })
return
}
setTransactions((prev) =>
prev.map((t) => (t.id === transactionId ? { ...t, is_ignored: false } : t))
)
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
} catch {
toast({ title: 'Kunde inte återställa transaktionen', variant: 'destructive' })
}
}
async function handleConfirmInvoiceMatch(opts?: {
force?: boolean
expected_journal_entry_id?: string
lines?: Array<{
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}>
}) {
if (!selectedTransaction) return
const isSupplier = !!selectedTransaction.potential_supplier_invoice
const isCustomer = !!selectedTransaction.potential_invoice
if (!isSupplier && !isCustomer) return
setIsConfirmingMatch(true)
try {
const url = isSupplier
? `/api/transactions/${selectedTransaction.id}/match-supplier-invoice`
: `/api/transactions/${selectedTransaction.id}/match-invoice`
const body: Record<string, unknown> = isSupplier
? { supplier_invoice_id: selectedTransaction.potential_supplier_invoice!.id }
: { invoice_id: selectedTransaction.potential_invoice!.id }
if (!isSupplier && opts?.force) {
body.force = true
// Bind the override to the candidate the user saw in the dialog.
// The server re-detects the candidate and rejects the bypass if
// the id doesn't match, so an empty value here surfaces as a
// clean validation error instead of silently widening the guard.
if (opts.expected_journal_entry_id) {
body.expected_journal_entry_id = opts.expected_journal_entry_id
}
}
// User-edited journal entry rows from the match dialog. Forwarded
// verbatim; the server validates balance and posts via
// createJournalEntry directly. Default routing applies when omitted.
if (opts?.lines && opts.lines.length >= 2) {
body.lines = opts.lines
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const result = await response.json()
if (!response.ok) {
toast({
title: isSupplier ? 'Leverantörsfakturamatchning misslyckades' : 'Fakturamatchning misslyckades',
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
setIsConfirmingMatch(false)
return
}
const label = isSupplier
? `Leverantörsfaktura ${selectedTransaction.potential_supplier_invoice!.supplier_invoice_number} markerad som betald`
: `Faktura ${selectedTransaction.potential_invoice!.invoice_number} markerad som betald`
toast({ title: isSupplier ? 'Leverantörsfaktura matchad' : 'Faktura matchad', description: label })
setMatchDialogOpen(false)
// Mark as exiting for animation
setExitingIds((prev) => new Set(prev).add(selectedTransaction.id))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === selectedTransaction.id
? isSupplier
? {
...t,
supplier_invoice_id: selectedTransaction.potential_supplier_invoice?.id || null,
potential_supplier_invoice: undefined,
is_business: true,
journal_entry_id: result.journal_entry_id,
}
: {
...t,
invoice_id: selectedTransaction.potential_invoice?.id || null,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: 'income_services' as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(selectedTransaction.id)
return next
})
setSelectedTransaction(null)
setIsConfirmingMatch(false)
}, 350)
} catch {
toast({ title: t('match_failed_title'), description: t('match_failed_transaction'), variant: 'destructive' })
setIsConfirmingMatch(false)
}
}
async function handleLinkToExistingVoucher(journalEntryId: string) {
if (!selectedTransaction) return
const invoiceId = selectedTransaction.potential_invoice?.id ?? null
setIsConfirmingMatch(true)
try {
const response = await fetch(
`/api/transactions/${selectedTransaction.id}/link-journal-entry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
journal_entry_id: journalEntryId,
...(invoiceId ? { invoice_id: invoiceId } : {}),
}),
},
)
const result = await response.json()
if (!response.ok) {
toast({
title: 'Kunde inte koppla till befintlig verifikation',
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
setIsConfirmingMatch(false)
return
}
const voucherLabel = (result as { voucher_label?: string }).voucher_label ?? ''
toast({
title: 'Bankhändelsen kopplad',
description: voucherLabel
? `Kopplad till verifikation ${voucherLabel}. Ingen ny bokföring skapad.`
: 'Ingen ny bokföring skapad.',
})
setMatchDialogOpen(false)
// Animate out + update local state, same pattern as handleConfirmInvoiceMatch.
setExitingIds((prev) => new Set(prev).add(selectedTransaction.id))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === selectedTransaction.id
? {
...t,
invoice_id: invoiceId,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
journal_entry_id: journalEntryId,
}
: t,
),
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(selectedTransaction.id)
return next
})
setSelectedTransaction(null)
setIsConfirmingMatch(false)
}, 350)
} catch {
toast({
title: 'Koppling misslyckades',
description: t('voucher_link_failed_description'),
variant: 'destructive',
})
setIsConfirmingMatch(false)
}
}
function openMatchVoucherDialog(transaction: TransactionWithInvoice) {
setMatchVoucherTx(transaction)
}
// Called by MatchVoucherDialog after /api/reconciliation/bank/link succeeds.
// The row is now booked (journal_entry_id set, is_business true) so the inbox
// filter drops it — animate it out the same way as the invoice-link path.
function handleVoucherLinked(transactionId: string, journalEntryId: string, voucherLabel: string) {
toast({
title: 'Bankhändelsen kopplad',
description: voucherLabel
? `Kopplad till verifikation ${voucherLabel}. Ingen ny bokföring skapad.`
: 'Ingen ny bokföring skapad.',
})
setMatchVoucherTx(null)
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? { ...t, is_business: true, journal_entry_id: journalEntryId }
: t,
),
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
}
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
try {
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: invoiceId }),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fakturamatchning misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' })
return false
}
const transaction = transactions.find((t) => t.id === transactionId)
const invoiceNumber = transaction?.potential_invoice?.invoice_number || ''
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? {
...t,
invoice_id: invoiceId,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: 'income_services' as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
: t
)
)
toast({ title: 'Faktura matchad', description: `Faktura ${invoiceNumber} markerad som betald` })
return true
} catch {
toast({ title: t('match_failed_title'), description: t('match_failed_with_invoice'), variant: 'destructive' })
return false
}
}
function handleSelectInvoiceFromPicker(invoice: Invoice & { customer?: Customer }) {
if (!invoicePickerTransaction) return
// Don't POST directly from the picker. Route through the confirm dialog
// so the user sees the JE preview (Debet 1930 / Kredit 1510, or the cash
// variant) before the booking is created. Same UX as the auto-suggested
// path. Closes the picker and opens the match dialog with the picked
// invoice attached as potential_invoice.
const tx = invoicePickerTransaction
setInvoicePickerOpen(false)
setInvoicePickerTransaction(null)
setSelectedTransaction({ ...tx, potential_invoice: invoice })
setMatchDialogOpen(true)
}
function handleSelectSupplierInvoiceFromPicker(invoice: SupplierInvoice & { supplier?: Supplier }) {
if (!supplierInvoicePickerTransaction) return
// Route through the confirm dialog so the supplier-side JE preview
// (Debet 2440 / Kredit 1930, or kontant-variant) is shown before commit.
const tx = supplierInvoicePickerTransaction
setSupplierInvoicePickerOpen(false)
setSupplierInvoicePickerTransaction(null)
setSelectedTransaction({ ...tx, potential_supplier_invoice: invoice })
setMatchDialogOpen(true)
}
function openInvoiceMatchPicker(transaction: TransactionWithInvoice) {
if (transaction.amount >= 0) {
setInvoicePickerTransaction(transaction)
setInvoicePickerOpen(true)
} else {
setSupplierInvoicePickerTransaction(transaction)
setSupplierInvoicePickerOpen(true)
}
}
function openSplitMatchDialog(transaction: TransactionWithInvoice) {
setSplitMatchTransaction(transaction)
setSplitMatchOpen(true)
}
// Selected-tx derivation for bulk-book eligibility.
// The action bar shows "Bokför i klump" only when ≥2 txs are selected,
// share the same date, and same direction (all income or all expense) —
// matches the RPC's same-day + same-direction invariants so the user
// doesn't submit a guaranteed-fail batch.
const selectedTransactions = useMemo(
() => transactions.filter((t) => selectedIds.has(t.id)),
[transactions, selectedIds],
)
const bulkBookEligible = useMemo(() => {
if (selectedTransactions.length < 2) return false
const first = selectedTransactions[0]!
return selectedTransactions.every(
(t) => t.date === first.date && (t.amount > 0) === (first.amount > 0),
)
}, [selectedTransactions])
async function handleBulkBookSuccess() {
// Animate every selected tx out of the inbox, then refetch and clear
// the selection state. Mirrors the per-tx match success animation.
const ids = Array.from(selectedIds)
setExitingIds((prev) => {
const next = new Set(prev)
for (const id of ids) next.add(id)
return next
})
await refreshTransactions()
setSelectedIds(new Set())
setIsBatchMode(false)
setTimeout(() => {
setExitingIds((prev) => {
const next = new Set(prev)
for (const id of ids) next.delete(id)
return next
})
}, 350)
}
async function handleSplitMatchSuccess() {
if (!splitMatchTransaction) return
const txId = splitMatchTransaction.id
// Mark the tx as exiting to trigger the same removal animation the
// single-match flow uses, then drop it from the inbox once the refetch
// confirms it's booked. Mirrors the pattern at the supplier-invoice
// match success path below.
setExitingIds((prev) => new Set(prev).add(txId))
await refreshTransactions()
setTimeout(() => {
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(txId)
return next
})
}, 350)
}
async function handleCreateTransaction(data: CreateTransactionInput) {
setIsCreating(true)
try {
// Create through the server route so the payload is validated server-side
// (shared CreateTransactionSchema) and the DB CHECK applies — the browser
// client must never be the only guard on a mutation.
const response = await fetch('/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: data.date,
description: data.description,
amount: data.amount,
currency: data.currency,
category: data.category,
notes: data.notes,
}),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Kunde inte skapa transaktion',
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
variant: 'destructive',
})
return
}
toast({ title: 'Transaktion tillagd', description: `${data.description} har lagts till` })
setTransactions([result.data, ...transactions])
setIsDialogOpen(false)
} catch {
toast({ title: 'Kunde inte skapa transaktion', description: t('booking_failed_description'), variant: 'destructive' })
} finally {
setIsCreating(false)
}
}
async function handleDeleteTransaction(id: string) {
const transaction = transactions.find((t) => t.id === id)
if (!transaction) return
const ok = await confirm({
title: 'Ta bort transaktion',
description: `Är du säker på att du vill ta bort "${transaction.description}"? Åtgärden kan inte ångras.`,
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
try {
const response = await fetch(`/api/transactions/${id}`, { method: 'DELETE' })
if (!response.ok) {
const result = await response.json()
toast({
title: 'Kunde inte ta bort',
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
return
}
setTransactions((prev) => prev.filter((t) => t.id !== id))
toast({ title: t('deleted_title'), description: t('deleted_description') })
} catch {
toast({
title: 'Kunde inte ta bort',
description: t('delete_failed_description'),
variant: 'destructive',
})
}
}
function openEditTitleDialog(transaction: TransactionWithInvoice) {
setEditTitleTarget(transaction)
}
// Persist a new title via PATCH. Returns true on success so the dialog can
// close; updates the local list optimistically (description + edited tag).
async function handleSaveTitle(description: string): Promise<boolean> {
const target = editTitleTarget
if (!target) return false
try {
const response = await fetch(`/api/transactions/${target.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description }),
})
const result = await response.json()
if (!response.ok) {
toast({
title: t('edit_title_failed'),
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
return false
}
const updated = result.data as { description: string; title_edited_at: string | null }
setTransactions((prev) =>
prev.map((tx) =>
tx.id === target.id
? { ...tx, description: updated.description, title_edited_at: updated.title_edited_at }
: tx,
),
)
toast({ title: t('edit_title_saved') })
return true
} catch {
toast({ title: t('edit_title_failed'), variant: 'destructive' })
return false
}
}
async function handleSkvBokfor(row: StoredSkattekontoTransaction) {
setSkvProcessingId(row.id)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${row.id}/bokfor`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Bokföring misslyckades')
}
toast({
title: 'Utkast skapat',
description: t('review_in_bookkeeping_description'),
})
window.location.href = `/bookkeeping/${json.data.entry.id}`
} catch (err) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setSkvProcessingId(null)
}
}
function handleSkvMatched() {
// After a successful match, drop the row from the inbox — it's now
// linked to a verifikat. Trigger an exit animation first.
if (skvMatchTarget) {
const id = skvMatchTarget.id
setExitingIds(prev => new Set(prev).add(id))
setTimeout(() => {
setSkvRows(prev => prev.filter(r => r.id !== id))
setExitingIds(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}, 350)
}
}
function handleTransactionBooked(
transactionId: string,
journalEntryId: string,
attachedDocumentId?: string | null,
) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? {
...t,
is_business: true,
journal_entry_id: journalEntryId,
// Existing pin wins — the link route only pins when the tx
// had none (document_id IS NULL guard).
document_id: t.document_id ?? attachedDocumentId ?? null,
}
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
setBookingDialogOpen(false)
setBookingDialogTransaction(null)
setBookingDialogTemplate(null)
toast({ title: 'Bokförd' })
}
function openAttachDocumentDialog(transaction: TransactionWithInvoice) {
setAttachDocTx(transaction)
}
function handleDocumentAttached(transactionId: string, documentId: string) {
setTransactions((prev) =>
prev.map((t) => (t.id === transactionId ? { ...t, document_id: documentId } : t))
)
// Booked row: the attach route propagated the doc onto the verifikation,
// so flip the JE status optimistically too. Read the JE id off the
// dialog's own subject (attachDocTx), not the transactions snapshot —
// the list may have changed (load-more, booking) while the dialog was
// open, and a stale find() would silently skip the badge flip.
const jeId =
attachDocTx?.id === transactionId ? attachDocTx.journal_entry_id : null
if (jeId) {
setJeUnderlagStatus((prev) => ({ ...prev, [jeId]: 'has' }))
}
}
// Batch mode handlers
function toggleBatchSelect(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function exitBatchMode() {
setIsBatchMode(false)
setSelectedIds(new Set())
}
async function handleBatchDelete() {
// Only user-created rows can be deleted; imported (bank sync / CSV) rows are
// ignore-only. Split the selection so we never fire a delete the server
// would 409, and tell the user how many were skipped. Mirrors the server
// guard in DELETE /api/transactions/[id].
const selected = Array.from(selectedIds)
const ids: string[] = []
let skippedImported = 0
for (const id of selected) {
const tx = transactions.find((t) => t.id === id)
if (tx && isImportedTransaction(tx)) skippedImported++
else ids.push(id)
}
if (ids.length === 0) {
toast({
title: 'Inget att ta bort',
description: 'De valda transaktionerna är importerade och kan endast ignoreras, inte raderas.',
variant: 'destructive',
})
return
}
const ok = await confirm({
title: `Ta bort ${ids.length} transaktioner?`,
description:
skippedImported > 0
? `${skippedImported} importerade transaktioner hoppas över (kan endast ignoreras). Åtgärden kan inte ångras.`
: 'Åtgärden kan inte ångras.',
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
const deletedIds = new Set<string>()
setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
for (let i = 0; i < ids.length; i++) {
try {
const response = await fetch(`/api/transactions/${ids[i]}`, { method: 'DELETE' })
if (response.ok) {
successes++
deletedIds.add(ids[i])
} else {
const tx = transactions.find((t) => t.id === ids[i])
failures.push(tx?.description || ids[i])
}
} catch {
failures.push(ids[i])
}
setBatchProgress({ done: i + 1, total: ids.length })
}
if (deletedIds.size > 0) {
setTransactions((prev) => prev.filter((t) => !deletedIds.has(t.id)))
}
setBatchProgress(null)
if (failures.length === 0 && skippedImported === 0) {
toast({ title: 'Klart', description: `${successes} transaktioner borttagna` })
} else {
const parts = [`${successes} borttagna`]
if (failures.length > 0) parts.push(`${failures.length} misslyckades`)
if (skippedImported > 0) parts.push(`${skippedImported} importerade kunde inte raderas`)
toast({
title: 'Delvis klart',
description: parts.join(', '),
variant: 'destructive',
})
}
exitBatchMode()
}
async function handleBatchIgnore() {
const ids = Array.from(selectedIds)
const ok = await confirm({
title: `Ignorera ${ids.length} transaktioner?`,
description: 'Transaktionerna försvinner från listan utan att bokföras. Du kan återställa dem under Bankavstämning.',
confirmLabel: 'Ignorera',
cancelLabel: 'Avbryt',
variant: 'warning',
})
if (!ok) return
const ignoredIds = new Set<string>()
setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
for (let i = 0; i < ids.length; i++) {
try {
const res = await fetch(`/api/transactions/${ids[i]}/ignore`, { method: 'POST' })
if (res.ok) {
successes++
ignoredIds.add(ids[i])
} else {
const tx = transactions.find((t) => t.id === ids[i])
failures.push(tx?.description || ids[i])
}
} catch {
failures.push(ids[i])
}
setBatchProgress({ done: i + 1, total: ids.length })
}
if (ignoredIds.size > 0) {
setExitingIds((prev) => {
const next = new Set(prev)
for (const id of ignoredIds) next.add(id)
return next
})
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? ignoredIds.size) - ignoredIds.size))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) => (ignoredIds.has(t.id) ? { ...t, is_ignored: true } : t))
)
setExitingIds((prev) => {
const next = new Set(prev)
for (const id of ignoredIds) next.delete(id)
return next
})
}, 350)
}
setBatchProgress(null)
if (failures.length === 0) {
toast({
title: 'Klart',
description: `${successes} transaktioner ignorerade`,
action: (
<ToastAction altText="Öppna Bankavstämning" asChild>
<Link href="/reports/bank-reconciliation">Bankavstämning</Link>
</ToastAction>
),
})
} else {
toast({
title: 'Delvis klart',
description: `${successes} ignorerade, ${failures.length} misslyckades`,
variant: 'destructive',
})
}
exitBatchMode()
}
async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) {
const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
for (let i = 0; i < ids.length; i++) {
const result = await handleCategorize(ids[i], true, category, vatTreatment)
if (result) {
successes++
} else {
const tx = transactions.find((t) => t.id === ids[i])
failures.push(tx?.description || ids[i])
}
setBatchProgress({ done: i + 1, total: ids.length })
}
setBatchProgress(null)
setShowBatchSelector(false)
if (failures.length === 0) {
toast({ title: 'Klart', description: `${successes} transaktioner bokförda` })
} else {
toast({
title: 'Delvis klart',
description: `${successes} lyckades, ${failures.length} misslyckades: ${failures.slice(0, 3).join(', ')}${failures.length > 3 ? '...' : ''}`,
variant: 'destructive',
})
}
exitBatchMode()
}
function openMatchDialog(transaction: TransactionWithInvoice) {
setSelectedTransaction(transaction)
setMatchDialogOpen(true)
}
function openCategoryDialog(transaction: TransactionWithInvoice) {
setTemplatePickerTransaction(transaction)
setTemplatePickerOpen(true)
}
function handleTemplateSelected(template: BookingTemplate) {
setTemplatePickerOpen(false)
const tx = templatePickerTransaction
if (!tx) return
// Library templates aren't validated server-side via template_id; the
// template's debit/credit + VAT drive the booking through account_override.
const templateId = isLibraryTemplateId(template.id) ? undefined : template.id
setQuickReview({ transaction: tx, category: template.fallback_category, label: template.name_sv, template, templateId, linePattern: null })
setQuickReviewOpen(true)
}
function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) {
if (isCounterpartyTemplateId(templateId)) {
const cpSuggestion = templateSuggestions[transaction.id]?.find(ts => ts.template_id === templateId)
if (!cpSuggestion) return
setQuickReview({
transaction,
category: transaction.amount < 0 ? 'expense_other' : 'income_services',
label: cpSuggestion.name_sv,
template: { id: templateId, name_sv: cpSuggestion.name_sv } as BookingTemplate,
templateId: undefined,
linePattern: cpSuggestion.line_pattern ?? null,
})
setQuickReviewOpen(true)
return
}
const template = getTemplateById(templateId)
if (!template) return
setQuickReview({
transaction,
category: template.fallback_category,
label: template.name_sv,
template,
templateId: template.id,
linePattern: null,
})
setQuickReviewOpen(true)
}
function handleChangeTemplate() {
setQuickReviewOpen(false)
if (quickReview?.transaction) {
setTemplatePickerTransaction(quickReview.transaction)
setTemplatePickerOpen(true)
}
}
function handleManualBooking() {
setTemplatePickerOpen(false)
if (templatePickerTransaction) {
setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogTemplate(null)
setBookingDialogOpen(true)
}
}
// Complex (multi-leg or otherwise non-convertible) library template picked
// from the transaction modal — route into the manual booking dialog with
// the template pre-applied against the transaction's amount.
function handlePickLibraryTemplate(raw: BookingTemplateLibrary) {
if (!templatePickerTransaction) return
setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogTemplate(raw)
setTemplatePickerOpen(false)
setBookingDialogOpen(true)
}
async function handleQuickReviewConfirm(
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined,
templateId?: string
): Promise<string | null> {
let journalEntryId: string | null
if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) {
const cpTemplateId = extractCounterpartyId(quickReview.template.id)
const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null }; status: number }> => {
const r = await fetch(`/api/transactions/${id}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_business: true, counterparty_template_id: cpTemplateId }),
})
const b = await r.json()
return { ok: r.ok, status: r.status, result: b, journalEntryId: b?.journal_entry_id || null }
}
const { ok: cpOk, status: cpStatus, result, journalEntryId: cpJeId } = await cpCategorize()
if (!cpOk) {
if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') {
const accountNumbers: string[] =
(Array.isArray(result.error.account_numbers) && result.error.account_numbers) ||
(Array.isArray(result.error.details?.account_numbers) && result.error.details?.account_numbers) ||
[]
// Synchronous in-flight flag per toast closure — see same pattern
// in runCategorize. Double-click on the counterparty-template
// retry would race the second cpCategorize against the first's
// verifikation insert.
let activateInFlight = false
toast({
title: 'Kontot finns inte i din kontoplan',
description: `Motpartsmallen kräver att följande konton aktiveras: ${accountNumbers.join(', ')}.`,
variant: 'destructive',
action: accountNumbers.length > 0 ? (
<ToastAction altText="Aktivera och bokför" onClick={async () => {
if (activateInFlight) return
activateInFlight = true
try {
const activateRes = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: accountNumbers }),
})
if (!activateRes.ok) {
const errBody = await activateRes.json().catch(() => null)
toast({ title: 'Kunde inte aktivera konton', description: getErrorMessage(errBody, { statusCode: activateRes.status }), variant: 'destructive' })
return
}
const activateBody = await activateRes.json()
if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) {
toast({ title: 'Kunde inte hitta alla konton', description: `Lägg till ${activateBody.unknown.join(', ')} manuellt under Inställningar → Kontoplan.`, variant: 'destructive' })
return
}
const retry = await cpCategorize()
// Gate on retry.ok alone: a 200 with null journal_entry_id
// is allowed by the declared type (e.g. already-categorized
// flag flip), and showing "Kategorisering misslyckades"
// after the server returned success is misleading. The state
// update conditionally writes the journal_entry_id when it's
// actually present.
if (retry.ok) {
setExitingIds((prev) => new Set(prev).add(id))
setTransactions((prev) =>
prev.map((t) =>
t.id === id
? { ...t, is_business: true, ...(retry.journalEntryId ? { journal_entry_id: retry.journalEntryId } : {}) }
: t
)
)
toast({ title: 'Bokförd' })
} else {
toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(retry.result, { context: 'transaction', statusCode: retry.status }), variant: 'destructive' })
}
} finally {
activateInFlight = false
}
}}>
Aktivera och bokför
</ToastAction>
) : undefined,
})
} else {
toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction', statusCode: cpStatus }), variant: 'destructive' })
}
// Close the review dialog on hard errors — the toast (with action if
// ACCOUNTS_NOT_IN_CHART) carries the message and the recovery path.
setQuickReviewOpen(false)
setQuickReview(null)
return null
}
setExitingIds((prev) => new Set(prev).add(id))
journalEntryId = cpJeId
} else {
journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
}
// Always close — whether the server created a verifikation, returned a
// structured 4xx (ACCOUNTS_NOT_IN_CHART, INVALID_MAPPING, …), or hit a
// partial-success path. The toast from runCategorize already communicates
// the outcome; keeping the dialog open serves no purpose.
setQuickReviewOpen(false)
setQuickReview(null)
return journalEntryId
}
return (
<div className="space-y-8">
{/* Status bar */}
<TransactionStatusBar
uncategorizedCount={totalUncategorizedCount ?? uncategorizedTransactions.length}
invoiceMatchCount={transactionsWithMatches.length}
mode={mode}
onOpenCreateDialog={() => setIsDialogOpen(true)}
isBatchMode={isBatchMode}
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
/>
<div className="flex flex-wrap items-center gap-2">
<BankSyncStatusChip />
<BankSyncNowButton />
{/* The ignore flows tell users to "återställ under Bankavstämning" —
this is the path there. Bankavstämning has no nav entry of its own,
so without a link here the copy points at an unreachable place. */}
<Button asChild variant="ghost" size="sm" className="ml-auto h-9 text-sm text-muted-foreground">
<Link href="/reports/bank-reconciliation">Bankavstämning →</Link>
</Button>
</div>
<BankSyncSinceLastVisit />
{/* Search + view dropdown */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök transaktioner…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-9 pl-10"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2 px-3 text-sm">
{mode === 'inbox'
? `Att bokföra${(totalUncategorizedCount ?? uncategorizedTransactions.length) > 0 ? ` (${totalUncategorizedCount ?? uncategorizedTransactions.length})` : ''}`
: 'Alla transaktioner'}
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[14rem]">
<DropdownMenuRadioGroup value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
<DropdownMenuRadioItem value="inbox">
{`Att bokföra${(totalUncategorizedCount ?? uncategorizedTransactions.length) > 0 ? ` (${totalUncategorizedCount ?? uncategorizedTransactions.length})` : ''}`}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="history">Alla transaktioner</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Content based on mode */}
{isLoading ? (
<DataList>
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-5 w-5 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48 rounded" />
<Skeleton className="h-3 w-24 rounded" />
</div>
<Skeleton className="h-5 w-20 rounded" />
</div>
))}
</DataList>
) : mode === 'inbox' ? (
inboxItems.length === 0 && !searchTerm ? (
<InboxZeroState
hasTransactions={transactions.length > 0 || skvRows.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
) : (
<DataList>
{skvUnmatched.length > 0 && uncategorizedTransactions.length > 0 && (
<DataListHeader>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{t('source_label')}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-2 px-2 text-xs">
{sourceFilter === 'all'
? t('source_all', { count: uncategorizedTransactions.length + skvUnmatched.length })
: sourceFilter === 'bank'
? t('source_bank', { count: uncategorizedTransactions.length })
: t('source_skatteverket', { count: skvUnmatched.length })}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[12rem]">
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(v) => setSourceFilter(v as typeof sourceFilter)}
>
<DropdownMenuRadioItem value="all">
{t('source_all', { count: uncategorizedTransactions.length + skvUnmatched.length })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bank">
{t('source_bank', { count: uncategorizedTransactions.length })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="skatteverket">
{t('source_skatteverket', { count: skvUnmatched.length })}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</DataListHeader>
)}
{inboxItems.length === 0 && searchTerm ? (
<DataListEmpty
title="Inga träffar"
description={t('no_search_results')}
/>
) : null}
<AnimatePresence mode="popLayout">
{inboxItems.map(item =>
item.source === 'bank' ? (
<TransactionInboxCard
key={`bank-${item.data.id}`}
transaction={item.data}
skvCounterpartDate={bankToSkvHints.get(item.data.id)}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(item.data.id)}
entityType={entityType}
onCategorize={handleCategorize}
onOpenMatchDialog={openMatchDialog}
onOpenMatchInvoicePicker={openInvoiceMatchPicker}
onOpenSplitMatch={openSplitMatchDialog}
onOpenMatchVoucher={openMatchVoucherDialog}
onOpenAttachDocument={openAttachDocumentDialog}
onOpenCategoryDialog={openCategoryDialog}
onDelete={handleDeleteTransaction}
onIgnore={handleIgnoreTransaction}
onEditTitle={openEditTitleDialog}
onToggleSelect={toggleBatchSelect}
/>
) : (
<SkattekontoInboxCard
key={`skv-${item.data.id}`}
row={item.data}
matchSuggestion={item.data.match_suggestion}
processing={skvProcessingId === item.data.id}
onBokfor={handleSkvBokfor}
onMatch={r => setSkvMatchTarget(r)}
/>
),
)}
</AnimatePresence>
</DataList>
)
) : (
<TransactionHistoryList
transactions={transactions}
skvRows={skvRows}
searchTerm={searchTerm}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onOpenAttachDocument={openAttachDocumentDialog}
onDelete={handleDeleteTransaction}
onSkvBokfor={handleSkvBokfor}
onSkvMatch={r => setSkvMatchTarget(r)}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
/>
)}
{/* Batch mode floating action bar */}
{isBatchMode && selectedIds.size > 0 && (
<div className="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-background border rounded-lg shadow-lg px-4 py-3">
{batchProgress ? (
<>
<Badge variant="secondary">
{batchProgress.done}/{batchProgress.total}
</Badge>
<p className="text-sm text-muted-foreground">
Bokför {batchProgress.done} av {batchProgress.total}...
</p>
</>
) : (
<>
<Badge variant="secondary">{selectedIds.size} valda</Badge>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBatchIgnore}
>
<EyeOff className="mr-1 h-3 w-3" />
Ignorera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBatchDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="mr-1 h-3 w-3" />
Ta bort
</Button>
{/* Bulk-book (samlingsverifikation) — only when ≥2 selected on
the same date + same direction. Disabled state explains why
via title. */}
<Button
variant="outline"
size="sm"
onClick={() => setBulkBookOpen(true)}
disabled={!bulkBookEligible}
title={
!bulkBookEligible
? 'Välj minst två transaktioner från samma datum och samma riktning'
: 'Skapa en samlingsverifikation för de valda transaktionerna'
}
>
<Layers className="mr-1 h-3 w-3" />
Bokför i klump
</Button>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
Bokför
</Button>
</>
)}
</div>
)}
{/* Dialogs */}
<BatchCategorySelector
open={showBatchSelector}
onOpenChange={setShowBatchSelector}
selectedCount={selectedIds.size}
onSelectCategory={handleBatchCategorize}
progress={batchProgress}
/>
<InvoiceMatchDialog
open={matchDialogOpen}
onOpenChange={setMatchDialogOpen}
transaction={selectedTransaction}
isConfirming={isConfirmingMatch}
onConfirm={handleConfirmInvoiceMatch}
onLinkToExisting={handleLinkToExistingVoucher}
/>
<MatchVoucherDialog
open={matchVoucherTx !== null}
onOpenChange={(o) => { if (!o) setMatchVoucherTx(null) }}
transaction={matchVoucherTx}
onLinked={handleVoucherLinked}
/>
<MatchAllocationDialog
open={splitMatchOpen}
onOpenChange={(o) => {
setSplitMatchOpen(o)
if (!o) setSplitMatchTransaction(null)
}}
transaction={splitMatchTransaction}
onSuccess={handleSplitMatchSuccess}
/>
<BulkBookDialog
open={bulkBookOpen}
onOpenChange={setBulkBookOpen}
transactions={selectedTransactions}
onSuccess={handleBulkBookSuccess}
/>
<TransactionBookingDialog
open={bookingDialogOpen}
onOpenChange={(o) => {
setBookingDialogOpen(o)
if (!o) setBookingDialogTemplate(null)
}}
transaction={bookingDialogTransaction}
preselectedTemplate={bookingDialogTemplate}
onBooked={handleTransactionBooked}
/>
<TransactionAttachDocumentDialog
open={attachDocTx !== null}
onOpenChange={(o) => {
if (!o) setAttachDocTx(null)
}}
transaction={attachDocTx}
onAttached={handleDocumentAttached}
/>
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
</DialogHeader>
{templatePickerTransaction && (
<div className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm">
<span className="truncate text-muted-foreground">{templatePickerTransaction.description}</span>
<span className="font-medium tabular-nums flex-shrink-0 ml-3">
{templatePickerTransaction.amount > 0 ? '+' : ''}{formatCurrency(templatePickerTransaction.amount, templatePickerTransaction.currency)}
</span>
</div>
)}
<div className="space-y-1">
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleManualBooking}
>
Bokför manuellt…
</Button>
{templatePickerTransaction && templatePickerTransaction.amount > 0 && (
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => {
const tx = templatePickerTransaction
setTemplatePickerOpen(false)
setInvoicePickerTransaction(tx)
setInvoicePickerOpen(true)
}}
>
Matcha med faktura…
</Button>
)}
{templatePickerTransaction && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground"
onClick={() => void handleIgnoreTransaction(templatePickerTransaction)}
>
Ignorera transaktionen…
</Button>
)}
</div>
<TemplatePicker
direction={templatePickerTransaction && templatePickerTransaction.amount < 0 ? 'expense' : 'income'}
entityType={entityType as EntityType}
suggestedTemplates={templatePickerTransaction ? templateSuggestions[templatePickerTransaction.id] : undefined}
onSelect={handleTemplateSelected}
onSelectCounterparty={(templateId) => {
if (!templatePickerTransaction) return
setTemplatePickerOpen(false)
handleOpenTemplateReview(templatePickerTransaction, templateId)
}}
onPickLibraryTemplate={handlePickLibraryTemplate}
/>
</DialogContent>
</Dialog>
<Dialog
open={invoicePickerOpen}
onOpenChange={(open) => {
if (isMatchingFromPicker) return
setInvoicePickerOpen(open)
if (!open) setInvoicePickerTransaction(null)
}}
>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('dialog_match_invoice')}</DialogTitle>
</DialogHeader>
{invoicePickerTransaction && (
<>
<div className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm">
<span className="truncate text-muted-foreground">{invoicePickerTransaction.description}</span>
<span className="font-medium tabular-nums flex-shrink-0 ml-3 text-success">
+{formatCurrency(invoicePickerTransaction.amount, invoicePickerTransaction.currency)}
</span>
</div>
<InvoicePicker
transaction={invoicePickerTransaction}
onSelect={handleSelectInvoiceFromPicker}
isProcessing={isMatchingFromPicker}
/>
</>
)}
</DialogContent>
</Dialog>
<Dialog
open={supplierInvoicePickerOpen}
onOpenChange={(open) => {
if (isMatchingSupplierFromPicker) return
setSupplierInvoicePickerOpen(open)
if (!open) setSupplierInvoicePickerTransaction(null)
}}
>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Matcha med leverantörsfaktura</DialogTitle>
</DialogHeader>
{supplierInvoicePickerTransaction && (
<>
<div className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm">
<span className="truncate text-muted-foreground">{supplierInvoicePickerTransaction.description}</span>
<span className="font-medium tabular-nums flex-shrink-0 ml-3">
{formatCurrency(supplierInvoicePickerTransaction.amount, supplierInvoicePickerTransaction.currency)}
</span>
</div>
<SupplierInvoicePicker
transaction={supplierInvoicePickerTransaction}
onSelect={handleSelectSupplierInvoiceFromPicker}
isProcessing={isMatchingSupplierFromPicker}
/>
</>
)}
</DialogContent>
</Dialog>
<QuickReviewDialog
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId) + String(quickReview?.template?.id)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReview?.transaction ?? null}
category={quickReview?.category ?? null}
categoryLabel={quickReview?.label ?? ''}
defaultAccount={
// For library templates (no templateId but a template object), use the
// template's debit account as the default; otherwise fall back to the
// category's default account.
!quickReview?.templateId && quickReview?.template
? quickReview.template.debit_account
: quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : ''
}
defaultVat={
!quickReview?.templateId && quickReview?.template
? (quickReview.template.vat_treatment ?? 'none')
: quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none'
}
entityType={entityType as EntityType}
template={quickReview?.template ?? null}
templateId={quickReview?.templateId}
counterpartyLinePattern={quickReview?.linePattern ?? null}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dialog_add_transaction')}</DialogTitle>
</DialogHeader>
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
</DialogContent>
</Dialog>
<DestructiveConfirmDialog {...confirmDialogProps} />
<EditTransactionTitleDialog
open={editTitleTarget !== null}
onOpenChange={(v) => {
if (!v) setEditTitleTarget(null)
}}
currentTitle={editTitleTarget?.description ?? ''}
originalTitle={editTitleTarget?.original_description ?? null}
onSave={handleSaveTitle}
/>
<SkattekontoMatchDialog
row={skvMatchTarget}
open={!!skvMatchTarget}
onClose={() => setSkvMatchTarget(null)}
onMatched={handleSkvMatched}
/>
{/* Prong B: match-against-supplier-invoice suggestion */}
<Dialog
open={siMatchSuggestion !== null}
onOpenChange={(open) => {
if (!open) setSiMatchSuggestion(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dialog_match_supplier_invoice')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Det finns en öppen leverantörsfaktura med samma belopp från samma leverantör. Matcha mot
fakturan istället för att bokföra direkt på leverantörsskuldskontot, annars skapas en
dubblerad verifikation som måste stornas (BFL 5 kap 5 §).
</p>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
{siMatchSuggestion?.candidates.map((c) => (
<div key={c.supplier_invoice_id} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<div className="font-medium">
{c.supplier_name || 'Leverantör'} · {c.invoice_number}
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatDate(c.invoice_date)} · kvar {formatCurrency(c.remaining_amount, c.currency)}
</div>
</div>
<Button
size="sm"
onClick={() => handleMatchSuggestedSupplierInvoice(siMatchSuggestion.transactionId, c.supplier_invoice_id)}
disabled={siMatchProcessing}
>
Matcha
</Button>
</div>
))}
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setSiMatchSuggestion(null)}>
Avbryt
</Button>
<Button
variant="outline"
onClick={async () => {
const retry = siMatchSuggestion?.retry
setSiMatchSuggestion(null)
if (retry) await retry()
}}
disabled={siMatchProcessing}
>
Bokför på leverantörsskulder ändå
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Prong B (customer side): match-against-customer-invoice suggestion */}
<Dialog
open={ciMatchSuggestion !== null}
onOpenChange={(open) => {
if (!open) setCiMatchSuggestion(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dialog_match_customer_invoice')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Det finns en obetald kundfaktura med samma belopp från samma kund. Matcha mot fakturan
istället för att bokföra direkt mot kundfordringskontot, annars skapas en dubblerad
verifikation som måste stornas (BFL 5 kap 5 §).
</p>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
{ciMatchSuggestion?.candidates.map((c) => (
<div key={c.invoice_id} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{c.customer_name || 'Kund'} · {c.invoice_number ?? '—'}
</span>
{c.match_reason === 'ocr_exact' && (
<Badge variant="success">{t('badge_exact_ocr')}</Badge>
)}
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatDate(c.invoice_date)} · kvar {formatCurrency(c.remaining_amount, c.currency)}
</div>
</div>
<Button
size="sm"
onClick={() => handleMatchSuggestedInvoice(ciMatchSuggestion.transactionId, c.invoice_id)}
disabled={ciMatchProcessing}
>
Matcha
</Button>
</div>
))}
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setCiMatchSuggestion(null)}>
Avbryt
</Button>
<Button
variant="outline"
onClick={async () => {
const retry = ciMatchSuggestion?.retry
setCiMatchSuggestion(null)
if (retry) await retry()
}}
disabled={ciMatchProcessing}
>
Bokför på kundfordringar ändå
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<DuplicateBookingDialog
candidate={duplicateWarning?.candidate ?? null}
processing={duplicateProcessing}
onCancel={() => setDuplicateWarning(null)}
onBookAnyway={async () => {
const retry = duplicateWarning?.retry
setDuplicateProcessing(true)
try {
setDuplicateWarning(null)
if (retry) await retry()
} finally {
setDuplicateProcessing(false)
}
}}
/>
</div>
)
}