Files
accounted/app/(dashboard)/transactions/page.tsx
T
Jakob WennbergandClaude Opus 4.6 a7be1aab17 feat: comprehensive UI design audit and normalization (#28)
* feat: import system improvements, INK2 fix, and Swedish text corrections

- SIE parser: Windows-1252 and CP437 encoding detection and decoding
- Bank file parser: add Nordea Business (Företag) CSV format
- Bank file parser: improve format detection for SEB, Länsförsäkringar, generic CSV
- INK2 engine: calculate årets resultat (7222) from income statement for open fiscal years
- Dashboard: parallel Supabase queries, simplified dashboard page
- Fix Swedish characters (å, ä, ö) in BAS data descriptions, validation messages, AI consent disclosures
- Import wizard UI improvements across all steps
- Migration: add 'bas_range' match type to sie_account_mappings constraint
- Extensive new tests for SIE parser encoding and bank file parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: arcim migration wizard UX fixes, Sentry setup, and extension scaffolding

Arcim migration wizard improvements:
- Progress bar now excludes non-interactive steps (migrating/result)
- Fix OAuth text to match target="_blank" behavior (new tab, not redirect)
- Display month names instead of "Månad X" in preview
- Fix Swedish typo "förifylla" in no-company-info message
- Replace native checkboxes with shadcn Switch in options step
- Add ConfirmationDialog before starting migration
- Show progress percentage during migration
- Add "Nästa steg" guidance and navigation links in result step
- Add "Försök igen" button in error state (returns to options)
- Add Bokio company ID help text (GUID from URL)
- Add Fortnox integration add-on hint on connection failure

Also includes: SIE import system improvements, INK2 fixes, Swedish text
corrections, Sentry error tracking setup, and arcim-migration extension
scaffolding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Fix OAuth error recovery blank page (restore provider from URL params)
- Pass real userId to MigrationWizard instead of empty string
- Remove ~50 debug console.log statements from sie-import.ts
- Fix comment referencing account 3740 → 3741

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: comprehensive UI design audit and normalization

Dashboard audit:
- Fix muted-foreground contrast (4.31:1 → 5.08:1) for WCAG AA
- Add prefers-reduced-motion media query for all animations
- Replace border-l-2 accent anti-pattern with subtle full-border colors
- Add aria-expanded to toggle buttons, role="status" to live counters
- Fix touch targets on deadline buttons (28px → 36px)
- Vary section spacing for rhythm (mb-12/mb-10/mb-8)
- Remove unused imports and dead code

Transactions audit + hardening:
- Add pagination (200 per page) with "Ladda fler" button
- Replace height animation with transform-only exit animation
- Show batch progress in floating action bar during processing
- Fix batch bar mobile overlap (bottom-20 on mobile)
- Replace clickable badges with proper button elements
- Add safe area padding to fullscreen swipe view
- Add response.ok check to suggestion fetch
- Add truncation to invoice number buttons

Invoicing audit:
- Remove border-l-4 accent pattern from invoice cards
- Replace string concatenation with cn() utility

Systemic sweep (34 files):
- All page headings: font-bold → font-display font-medium (Fraunces)
- All stat numbers: font-bold → font-display font-medium tabular-nums
- All hard-coded blue/amber/emerald colors → design tokens
- Remove all dark mode overrides (tokens handle automatically)
- Tint pure white card background to 99%

Design context added to CLAUDE.md with brand personality,
aesthetic direction, and 5 design principles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bookkeeping flow audit — design system, accessibility, UX

- Replace raw <select> with shadcn Select component (JournalEntryForm)
- Add confirmation dialog for account deletion (ChartOfAccountsManager)
- Remove console.error from production code (JournalEntryList, JournalEntryForm)
- Fix contradictory h-7/min-h-[44px] button sizing → h-10 (ChartOfAccountsManager)
- Increase BAS catalog "Lägg till" touch target h-7 → h-9
- Improve loading state with spinner (JournalEntryList)
- Improve empty state with icon, description, and guidance (JournalEntryList)
- Add response.ok check on journal entry fetch
- Add aria-expanded to entry expand buttons
- Add tabular-nums to desktop debit/credit columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: onboarding and empty state improvements

Onboarding:
- Replace font-serif with font-display (Fraunces) for brand consistency
- Remove console.error calls from production code

Empty states:
- Fix broken /transactions/new link in EmptyTransactions (route doesn't exist)
- Add actionHref fallback to EmptyCustomers when no onAction prop provided
- Improve EmptyTransactions description copy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clarify Swedish UX copy — terminology, errors, descriptions

Terminology consistency:
- "Försenad" → "Förfallen" for overdue invoices (customers/[id])
- "bokföringsorder" → actionable description in bookkeeping page
- "verifikation har bifogats" → "underlag har bifogats" in doc warning
- "Fortsätt ändå" → "Bokför utan underlag" (specific action)

Error messages — replace generic "Fel" + "Något gick fel" with specific:
- "Något gick fel vid bokföring" → "Transaktionen kunde inte bokföras"
- "Något gick fel vid matchning" → "Transaktionen kunde inte matchas"
- "Kunde inte hämta X" → "Kunde inte ladda X" + recovery hint
- Add "Försök igen" guidance to all error toasts

Page descriptions — replace redundant with actionable:
- Invoices: "Skapa och hantera" → "Skicka, följ betalningar, skapa kreditnotor"
- Bookkeeping: list of features → actionable description

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: design critique — dashboard affordance, reports description

Dashboard:
- Add ChevronRight indicator to clickable summary cards
  (Att få betalt, Koppla bank) to distinguish from static cards
- Add cursor-pointer to linked cards

Reports:
- Replace feature list description with actionable guidance
  "Huvudbok, grundbok..." → "Generera skattedeklarationer..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace generic "Fel" error toasts with specific messages

Deadlines: 5 generic "Fel" → specific per-action titles
  (create, toggle, edit, delete, load)
Expenses detail: 5 generic "Fel" → specific per-action titles
  (load, approve, pay, credit, delete)
Expenses new: 3 generic "Fel" → instructional validation messages
  (supplier name, supplier selection, invoice number)
Customers: 1 generic "Fel" → specific load error with recovery hint

All error toasts now follow pattern:
  title = what failed, description = how to recover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace all remaining generic "Fel" error toasts (37 instances)

Systematic sweep across 12 dashboard pages replacing generic
title: 'Fel' with context-specific error titles:

- Load errors: "Kunde inte ladda [resurs]"
- Action errors: "[Åtgärd] misslyckades"
- Validation: "[Fält] saknas"

Every error toast now tells the user what failed without needing
to read the description. Recovery hints added where missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: import flow — normalize stat typography, remove console.warn

- Replace font-bold with font-display font-medium on 13 stat numbers
  across SIEPreviewStep, BankFilePreviewStep, BankFileConfirmStep,
  ImportResultStep (missed by systemic sweep since these are in
  components/import/, not app/(dashboard)/)
- Add tabular-nums to stat numbers displaying counts/currency
- Remove console.warn in ArcimMigrationWorkspace

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: final cleanup — console statements, remaining font-bold stats

Remove production console statements:
- Step1EntityType: remove debug console.warn (dead code after onNext)
- TransactionBookingDialog: remove console.error on doc link failure
- JournalEntryAttachments: remove 3 console.error calls

Normalize remaining font-bold stat displays:
- SwipeCategorizationView: 3 instances (completion, amount displays)
- NEDeclarationView: yearly result heading + value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review feedback

loadMoreTransactions: add inbox item enrichment matching fetchTransactions
- Paginated transactions now fetch invoice_inbox_items in parallel
- Fixes missing document indicator, template suggestions, and inbox
  match card for transactions loaded via "Ladda fler"

fetchAllPages: add maxPages guard (default 500) to prevent infinite loop
- If Arcim gateway returns hasMore:true indefinitely, the loop now
  exits after 500 pages instead of running forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:48:40 +01:00

906 lines
34 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { AnimatePresence } from 'framer-motion'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import TemplatePicker from '@/components/transactions/TemplatePicker'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
export default function TransactionsPage() {
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 [showSwipeView, setShowSwipeView] = useState(false)
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
const [templateSuggestions, setTemplateSuggestions] = useState<Record<string, SuggestedTemplate[]>>({})
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
const [processingId, setProcessingId] = useState<string | null>(null)
// 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)
// Template picker dialog
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [templatePickerTransaction, setTemplatePickerTransaction] = useState<TransactionWithInvoice | null>(null)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
const [quickReviewTransaction, setQuickReviewTransaction] = useState<TransactionWithInvoice | null>(null)
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
const [quickReviewTemplate, setQuickReviewTemplate] = useState<BookingTemplate | null>(null)
const [quickReviewTemplateId, setQuickReviewTemplateId] = useState<string | undefined>(undefined)
// Describe dialog
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
const [describeDialogTransaction, setDescribeDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
// Pagination
const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const { toast } = useToast()
const supabase = createClient()
// Computed lists
const uncategorizedTransactions = transactions
.filter((t) => t.is_business === null && !exitingIds.has(t.id))
.sort((a, b) => {
const aHasMatch = a.potential_invoice ? 1 : 0
const bHasMatch = b.potential_invoice ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
})
const transactionsWithMatches = transactions.filter((t) => t.potential_invoice && !t.invoice_id)
const PAGE_SIZE = 200
async function fetchTransactions() {
setIsLoading(true)
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.order('date', { ascending: false })
.limit(PAGE_SIZE)
if (txError) {
toast({ title: 'Kunde inte ladda transaktioner', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive' })
setIsLoading(false)
return
}
const potentialInvoiceIds = (txData || [])
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const unbookedTxIds = (txData || [])
.filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id)
// Fetch invoices and inbox items in parallel
const [invoiceResult, inboxResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
unbookedTxIds.length > 0
? supabase.from('invoice_inbox_items').select('*').in('matched_transaction_id', unbookedTxIds).in('status', ['ready', 'processing'])
: Promise.resolve({ data: null }),
])
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (invoiceResult.data) {
invoiceMap = invoiceResult.data.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (inboxResult.data) {
inboxItemMap = inboxResult.data.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
matched_inbox_item: inboxItemMap[t.id] || undefined,
}))
setTransactions(transactionsWithInvoices)
setHasMore((txData || []).length >= PAGE_SIZE)
setIsLoading(false)
}
async function loadMoreTransactions() {
setIsLoadingMore(true)
const offset = transactions.length
const { data: txData, error: txError } = await supabase
.from('transactions')
.select('*')
.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 unbookedTxIds = txData
.filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id)
const [invoiceResult, inboxResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
unbookedTxIds.length > 0
? supabase.from('invoice_inbox_items').select('*').in('matched_transaction_id', unbookedTxIds).in('status', ['ready', 'processing'])
: Promise.resolve({ data: null }),
])
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (invoiceResult.data) {
invoiceMap = invoiceResult.data.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (inboxResult.data) {
inboxItemMap = inboxResult.data.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
matched_inbox_item: inboxItemMap[t.id] || undefined,
}))
setTransactions((prev) => [...prev, ...newTransactions])
setIsLoadingMore(false)
}
async function fetchCategorySuggestions(txIds: string[]) {
if (txIds.length === 0) return
setIsLoadingSuggestions(true)
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.suggestions) {
setCategorySuggestions(data.suggestions)
}
if (data.template_suggestions) {
setTemplateSuggestions(data.template_suggestions)
}
} catch {
// Non-critical
}
setIsLoadingSuggestions(false)
}
// 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(),
fetch('/api/settings').then(r => r.json()).catch(() => null),
])
if (cancelled) return
if (entityRes?.entity_type) {
setEntityType(entityRes.entity_type)
}
}
loadAll()
return () => { cancelled = true }
}, [])
// 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) => {
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,
}),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Kategorisering misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' })
setProcessingId(null)
return null
}
// Mark as exiting for animation, then update state
setExitingIds((prev) => new Set(prev).add(id))
if (result.journal_entry_created) {
toast({
title: 'Bokförd',
description: 'Transaktion bokförd och verifikation skapad',
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
)
)
toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' })
} else {
const errData = await undoRes.json()
toast({ title: 'Kunde inte ångra', description: errData.error || 'Kategoriseringen kunde inte ångras. Försök igen.', variant: 'destructive' })
}
} catch {
toast({ title: 'Kunde inte ångra', description: 'Kategoriseringen kunde inte ångras. Försök igen.', 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: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' })
}
// 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: 'Bokföring misslyckades', description: 'Transaktionen kunde inte bokföras. Försök igen.', variant: 'destructive' })
setProcessingId(null)
return null
}
}
async function handleMarkPrivate(id: string) {
await handleCategorize(id, false, 'private')
}
async function handleConfirmInvoiceMatch() {
if (!selectedTransaction?.potential_invoice) return
setIsConfirmingMatch(true)
try {
const response = await fetch(`/api/transactions/${selectedTransaction.id}/match-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: selectedTransaction.potential_invoice.id }),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fakturamatchning misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' })
setIsConfirmingMatch(false)
return
}
toast({
title: 'Faktura matchad',
description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`,
})
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
? {
...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: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.', variant: 'destructive' })
setIsConfirmingMatch(false)
}
}
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: result.error || 'Försök igen.', 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: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.', variant: 'destructive' })
return false
}
}
async function handleCreateTransaction(data: CreateTransactionInput) {
setIsCreating(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
toast({ title: 'Inloggning krävs', description: 'Du måste vara inloggad för att lägga till transaktioner.', variant: 'destructive' })
setIsCreating(false)
return
}
const { data: transaction, error } = await supabase
.from('transactions')
.insert({
user_id: user.id,
date: data.date,
description: data.description,
amount: data.amount,
currency: data.currency,
category: data.category || 'uncategorized',
is_business: data.is_business,
notes: data.notes,
})
.select()
.single()
if (error) {
toast({ title: 'Kunde inte skapa transaktion', description: error.message, variant: 'destructive' })
} else {
toast({ title: 'Transaktion tillagd', description: `${data.description} har lagts till` })
setTransactions([transaction, ...transactions])
setIsDialogOpen(false)
}
setIsCreating(false)
}
function handleTransactionBooked(transactionId: string, journalEntryId: string) {
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)
setBookingDialogOpen(false)
setBookingDialogTransaction(null)
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
}
// 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 handleBatchMarkPrivate() {
const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length })
for (let i = 0; i < ids.length; i++) {
await handleCategorize(ids[i], false, 'private')
setBatchProgress({ done: i + 1, total: ids.length })
}
setBatchProgress(null)
toast({ title: 'Klart', description: `${ids.length} transaktioner markerade som privat` })
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()
}
async function openSwipeView() {
try {
// Match invoices to transactions
await fetch('/api/transactions/batch-match-invoices', { method: 'POST' })
.then((r) => r.json())
.then((data) => {
if (data.matched > 0) fetchTransactions()
})
} catch {
// Non-critical
}
try {
// Run document matching sweep for latest inbox matches
await fetch('/api/documents/match-sweep', { method: 'POST' })
.then((r) => r.json())
.then((data) => {
if (data.data?.matched > 0) fetchTransactions()
})
} catch {
// Non-critical
}
const uncatIds = uncategorizedTransactions.map((t) => t.id)
await fetchCategorySuggestions(uncatIds)
setShowSwipeView(true)
}
function openMatchDialog(transaction: TransactionWithInvoice) {
setSelectedTransaction(transaction)
setMatchDialogOpen(true)
}
function openCategoryDialog(transaction: TransactionWithInvoice) {
setTemplatePickerTransaction(transaction)
setTemplatePickerOpen(true)
}
function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) {
const allCategories = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES]
const label = allCategories.find((c) => c.value === suggestion.category)?.label || suggestion.label
setQuickReviewTransaction(transaction)
setQuickReviewCategory(suggestion.category)
setQuickReviewLabel(label)
setQuickReviewOpen(true)
}
function handleTemplateSelected(template: BookingTemplate) {
setTemplatePickerOpen(false)
const tx = templatePickerTransaction
if (!tx) return
setQuickReviewTransaction(tx)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) {
const template = getTemplateById(templateId)
if (!template) return
setQuickReviewTransaction(transaction)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleChangeTemplate() {
setQuickReviewOpen(false)
if (quickReviewTransaction) {
setTemplatePickerTransaction(quickReviewTransaction)
setTemplatePickerOpen(true)
}
}
function handleManualBooking() {
setTemplatePickerOpen(false)
if (templatePickerTransaction) {
setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogOpen(true)
}
}
async function handleQuickReviewConfirm(
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined,
templateId?: string
): Promise<string | null> {
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
if (journalEntryId) {
setQuickReviewOpen(false)
setQuickReviewTransaction(null)
setQuickReviewCategory(null)
setQuickReviewTemplate(null)
setQuickReviewTemplateId(undefined)
}
return journalEntryId
}
function openDescribeDialog(transaction: TransactionWithInvoice) {
setDescribeDialogTransaction(transaction)
setDescribeDialogOpen(true)
}
function handleDescribeCategorized(transactionId: string, journalEntryId: 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 }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
}
function handleBatchApplied() {
fetchTransactions()
}
// Swipe view
if (showSwipeView && uncategorizedTransactions.length > 0) {
return (
<SwipeCategorizationView
transactions={uncategorizedTransactions}
suggestions={categorySuggestions}
templateSuggestions={templateSuggestions}
onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)}
entityType={entityType as EntityType}
/>
)
}
return (
<div className="space-y-6">
{/* Status bar with mode toggle */}
<TransactionStatusBar
uncategorizedCount={uncategorizedTransactions.length}
invoiceMatchCount={transactionsWithMatches.length}
mode={mode}
onModeChange={setMode}
onOpenSwipeView={openSwipeView}
onOpenCreateDialog={() => setIsDialogOpen(true)}
isLoadingSuggestions={isLoadingSuggestions}
isBatchMode={isBatchMode}
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
/>
{/* Content based on mode */}
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-5 bg-muted rounded w-48" />
<div className="h-4 bg-muted rounded w-24" />
</div>
<div className="h-6 bg-muted rounded w-20" />
</div>
</CardContent>
</Card>
))}
</div>
) : mode === 'inbox' ? (
uncategorizedTransactions.length === 0 ? (
<InboxZeroState
hasTransactions={transactions.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
) : (
<div className="space-y-2">
<AnimatePresence mode="popLayout">
{uncategorizedTransactions.map((transaction) => (
<TransactionInboxCard
key={transaction.id}
transaction={transaction}
suggestions={categorySuggestions[transaction.id]}
templateSuggestions={templateSuggestions[transaction.id]}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(transaction.id)}
entityType={entityType}
onCategorize={handleCategorize}
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
))}
</AnimatePresence>
</div>
)
) : (
<TransactionHistoryList
transactions={transactions}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
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-xl 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={handleBatchMarkPrivate}>
Markera som privat
</Button>
<Button size="sm" onClick={() => setShowBatchSelector(true)}>
Bokför {selectedIds.size} st
</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}
/>
<TransactionBookingDialog
open={bookingDialogOpen}
onOpenChange={setBookingDialogOpen}
transaction={bookingDialogTransaction}
onBooked={handleTransactionBooked}
/>
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Välj mall</DialogTitle>
</DialogHeader>
<TemplatePicker
direction={templatePickerTransaction && templatePickerTransaction.amount < 0 ? 'expense' : 'income'}
entityType={entityType as EntityType}
suggestedTemplates={templatePickerTransaction ? templateSuggestions[templatePickerTransaction.id] : undefined}
onSelect={handleTemplateSelected}
/>
<div className="pt-2 border-t">
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={handleManualBooking}>
Ange konton manuellt...
</Button>
</div>
</DialogContent>
</Dialog>
<QuickReviewDialog
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory) + String(quickReviewTemplateId)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReviewTransaction}
category={quickReviewCategory}
categoryLabel={quickReviewLabel}
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
entityType={entityType as EntityType}
template={quickReviewTemplate}
templateId={quickReviewTemplateId}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
<DescribeTransactionDialog
open={describeDialogOpen}
onOpenChange={setDescribeDialogOpen}
transaction={describeDialogTransaction}
onCategorized={handleDescribeCategorized}
onBatchApplied={handleBatchApplied}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lägg till transaktion</DialogTitle>
</DialogHeader>
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
</DialogContent>
</Dialog>
</div>
)
}