'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 { 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([]) const [isLoading, setIsLoading] = useState(true) const [mode, setMode] = useState('inbox') const [isDialogOpen, setIsDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) const [showSwipeView, setShowSwipeView] = useState(false) const [categorySuggestions, setCategorySuggestions] = useState>({}) const [templateSuggestions, setTemplateSuggestions] = useState>({}) const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false) const [processingId, setProcessingId] = useState(null) // Batch mode const [isBatchMode, setIsBatchMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(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(null) const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) // Booking dialog (journal entry form) const [bookingDialogOpen, setBookingDialogOpen] = useState(false) const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) // Template picker dialog const [templatePickerOpen, setTemplatePickerOpen] = useState(false) const [templatePickerTransaction, setTemplatePickerTransaction] = useState(null) // Quick review dialog (suggestion review before booking) const [quickReviewOpen, setQuickReviewOpen] = useState(false) const [quickReviewTransaction, setQuickReviewTransaction] = useState(null) const [quickReviewCategory, setQuickReviewCategory] = useState(null) const [quickReviewLabel, setQuickReviewLabel] = useState('') const [quickReviewTemplate, setQuickReviewTemplate] = useState(null) const [quickReviewTemplateId, setQuickReviewTemplateId] = useState(undefined) // Describe dialog const [describeDialogOpen, setDescribeDialogOpen] = useState(false) const [describeDialogTransaction, setDescribeDialogTransaction] = useState(null) // Entity type for tooltip context const [entityType, setEntityType] = useState('enskild_firma') // Set of transaction IDs that are animating out (just categorized) const [exitingIds, setExitingIds] = useState>(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) async function fetchTransactions() { setIsLoading(true) const { data: txData, error: txError } = await supabase .from('transactions') .select('*') .order('date', { ascending: false }) if (txError) { toast({ title: 'Fel', description: 'Kunde inte hämta transaktioner', variant: 'destructive' }) setIsLoading(false) return } const potentialInvoiceIds = (txData || []) .filter((t) => t.potential_invoice_id) .map((t) => t.potential_invoice_id) let invoiceMap: Record = {} if (potentialInvoiceIds.length > 0) { const { data: invoices } = await supabase .from('invoices') .select('*, customer:customers(*)') .in('id', potentialInvoiceIds) if (invoices) { invoiceMap = invoices.reduce((acc, inv) => { acc[inv.id] = inv return acc }, {} as Record) } } // Fetch matched inbox items for unbooked transactions const unbookedTxIds = (txData || []) .filter((t) => !t.journal_entry_id && t.is_business === null) .map((t) => t.id) let inboxItemMap: Record = {} if (unbookedTxIds.length > 0) { const { data: inboxItems } = await supabase .from('invoice_inbox_items') .select('*') .in('matched_transaction_id', unbookedTxIds) .in('status', ['ready', 'processing']) if (inboxItems) { inboxItemMap = inboxItems.reduce((acc, item) => { if (item.matched_transaction_id) { acc[item.matched_transaction_id] = item as InvoiceInboxItem } return acc }, {} as Record) } } 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) setIsLoading(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 }), }) 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 on mount // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { fetchTransactions() }, []) // Fetch entity type for tooltip context useEffect(() => { async function fetchEntityType() { try { const res = await fetch('/api/settings') const data = await res.json() if (data?.entity_type) { setEntityType(data.entity_type) } } catch { // Non-critical, defaults to enskild_firma } } fetchEntityType() }, []) // 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) } }, [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: 'Fel', description: result.error || 'Kunde inte uppdatera transaktion', 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' }) } 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: 'Fel', description: 'Något gick fel vid bokföring', 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: 'Fel', description: result.error || 'Kunde inte matcha faktura', 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: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' }) setIsConfirmingMatch(false) } } async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise { 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: 'Fel', description: result.error || 'Kunde inte matcha faktura', 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: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' }) return false } } async function handleCreateTransaction(data: CreateTransactionInput) { setIsCreating(true) const { data: { user } } = await supabase.auth.getUser() if (!user) { toast({ title: 'Fel', description: 'Du måste vara inloggad', 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: 'Fel', 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 { 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 ( setShowSwipeView(false)} entityType={entityType as EntityType} /> ) } return (
{/* Status bar with mode toggle */} setIsDialogOpen(true)} isLoadingSuggestions={isLoadingSuggestions} isBatchMode={isBatchMode} onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))} /> {/* Content based on mode */} {isLoading ? (
{[1, 2, 3].map((i) => (
))}
) : mode === 'inbox' ? ( uncategorizedTransactions.length === 0 ? ( 0} onCreateTransaction={() => setIsDialogOpen(true)} /> ) : (
{uncategorizedTransactions.map((transaction) => ( ))}
) ) : ( )} {/* Batch mode floating action bar */} {isBatchMode && selectedIds.size > 0 && (
{selectedIds.size} valda
)} {/* Dialogs */} Välj mall
Lägg till transaktion
) }