* 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>
906 lines
34 KiB
TypeScript
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>
|
|
)
|
|
}
|