'use client' import { useState, useCallback, useEffect } from 'react' import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } from 'framer-motion' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import VatTreatmentSelect from './VatTreatmentSelect' import { formatCurrency, formatDate } from '@/lib/utils' import { checkExpenseWarnings } from '@/lib/tax/expense-warnings' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' import TemplatePicker from './TemplatePicker' import JournalEntryPreview from './JournalEntryPreview' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp, MessageSquareText } from 'lucide-react' import DescribeTransactionDialog from './DescribeTransactionDialog' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types' interface SwipeCategorizationViewProps { transactions: TransactionWithInvoice[] suggestions?: Record templateSuggestions?: Record onCategorize: CategorizeHandler onMatchInvoice?: MatchInvoiceHandler onClose: () => void entityType?: EntityType } const expenseCategories = EXPENSE_CATEGORIES const incomeCategories = INCOME_CATEGORIES export default function SwipeCategorizationView({ transactions, suggestions, templateSuggestions, onCategorize, onMatchInvoice, onClose, entityType, }: SwipeCategorizationViewProps) { const { toast } = useToast() const [currentIndex, setCurrentIndex] = useState(0) const [showCategorySelect, setShowCategorySelect] = useState(false) const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState(null) // Review step state const [showReviewStep, setShowReviewStep] = useState(false) const [pendingCategory, setPendingCategory] = useState(null) const [accountOverride, setAccountOverride] = useState('') const [vatTreatment, setVatTreatment] = useState('standard_25') const [accounts, setAccounts] = useState([]) const [uploadedFiles, setUploadedFiles] = useState([]) const [showUploadZone, setShowUploadZone] = useState(false) const [showDescribeDialog, setShowDescribeDialog] = useState(false) const [showVatDropdown, setShowVatDropdown] = useState(false) const [pendingTemplateId, setPendingTemplateId] = useState(null) const [pendingInboxItemId, setPendingInboxItemId] = useState(null) // Clear VAT treatment when switching to a liability/equity account (class 2) useEffect(() => { if (accountOverride.startsWith('2') && vatTreatment !== 'none') { setVatTreatment('none') } }, [accountOverride]) // eslint-disable-line react-hooks/exhaustive-deps // Fetch accounts on mount useEffect(() => { async function fetchAccounts() { try { const res = await fetch('/api/bookkeeping/accounts') const data = await res.json() if (data.accounts) { setAccounts(data.accounts) } } catch { // Non-critical, AccountCombobox will just be empty } } fetchAccounts() }, []) const currentTransaction = transactions[currentIndex] const warnings = currentTransaction ? checkExpenseWarnings(currentTransaction.description) : [] const x = useMotionValue(0) const rotate = useTransform(x, [-200, 0, 200], [-15, 0, 15]) const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0.5, 1, 1, 1, 0.5]) const businessIndicatorOpacity = useTransform(x, [0, 100, 200], [0, 0.5, 1]) const skipIndicatorOpacity = useTransform(x, [-200, -100, 0], [1, 0.5, 0]) const moveToNext = useCallback(() => { x.set(0) if (currentIndex < transactions.length - 1) { setCurrentIndex(currentIndex + 1) } else { onClose() } }, [x, currentIndex, transactions.length, onClose]) const handleDrag = useCallback( (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { if (isProcessing) return x.set(info.offset.x) }, [isProcessing, x] ) const handleCategorySelect = useCallback((category: TransactionCategory) => { // Set up review step with defaults for this category const defaultAccount = getDefaultAccountForCategory(category) const defaultVat = getDefaultVatTreatmentForCategory(category) setPendingCategory(category) setAccountOverride(defaultAccount) setVatTreatment(defaultVat ?? 'none') setPendingTemplateId(null) setPendingInboxItemId(null) setShowVatDropdown(false) setShowCategorySelect(false) setShowReviewStep(true) setError(null) }, []) const handlePickerTemplateSelect = useCallback((template: BookingTemplate) => { setPendingCategory(template.fallback_category) setAccountOverride(template.debit_account) setVatTreatment(template.vat_treatment ?? 'none') setPendingTemplateId(template.id) setPendingInboxItemId(null) setShowVatDropdown(false) setShowCategorySelect(false) setShowReviewStep(true) setError(null) }, []) const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => { const template = getTemplateById(templateId) if (!template) return setPendingCategory(template.fallback_category) setAccountOverride(template.debit_account) setVatTreatment(template.vat_treatment ?? 'none') setPendingTemplateId(templateId) setPendingInboxItemId(inboxItemId ?? null) setShowVatDropdown(false) setShowCategorySelect(false) setShowReviewStep(true) setError(null) }, []) const handleDragEnd = useCallback( async (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { if (isProcessing || !currentTransaction) return const mx = info.offset.x const vx = Math.abs(info.velocity.x) const shouldSwipe = Math.abs(mx) > 100 || vx > 500 if (shouldSwipe) { if (mx > 0) { // Swipe right = categorize as business if (currentTransaction.amount < 0) { // Show category selector for business expenses setShowCategorySelect(true) x.set(0) } else { // Income: go to review step with income_other default handleCategorySelect('income_other') x.set(0) } } else { // Swipe left = skip moveToNext() } } else { x.set(0) } }, [isProcessing, currentTransaction, handleCategorySelect, x, moveToNext] ) const resetUploadState = useCallback(() => { setUploadedFiles([]) setShowUploadZone(false) }, []) const handleReviewConfirm = async () => { if (!pendingCategory) return setIsProcessing(true) setError(null) try { const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment const defaultAccount = getDefaultAccountForCategory(pendingCategory) // Only send override if it differs from the default const override = accountOverride && accountOverride !== defaultAccount ? accountOverride : undefined const journalEntryId = await onCategorize( currentTransaction.id, true, pendingCategory, resolvedVat, override, pendingTemplateId ?? undefined, pendingInboxItemId ?? undefined ) if (journalEntryId) { // Link uploaded documents to the journal entry if (uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) let linkFailCount = 0 for (const file of filesToLink) { try { await fetch(`/api/documents/${file.id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }) } catch { linkFailCount++ } } if (linkFailCount > 0) { toast({ title: 'Underlag kunde inte bifogas', description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`, variant: 'destructive', }) } } resetUploadState() setShowReviewStep(false) setPendingCategory(null) setPendingTemplateId(null) setPendingInboxItemId(null) moveToNext() } else { setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.') } } catch { setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.') } finally { setIsProcessing(false) } } const handleMatchInvoice = async () => { if (!currentTransaction.potential_invoice || !onMatchInvoice) return setIsProcessing(true) setError(null) try { const success = await onMatchInvoice( currentTransaction.id, currentTransaction.potential_invoice.id ) if (success) { moveToNext() } else { setError('Kunde inte matcha faktura. Tryck "Hoppa över" för att gå vidare.') } } catch { setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.') } finally { setIsProcessing(false) } } const handleSkip = useCallback(() => { setError(null) setShowCategorySelect(false) setShowReviewStep(false) setPendingCategory(null) setPendingTemplateId(null) setPendingInboxItemId(null) resetUploadState() moveToNext() }, [moveToNext, resetUploadState]) if (!currentTransaction) { return (

Klart!

Alla transaktioner är nu bokförda

) } if (showCategorySelect) { const direction = currentTransaction.amount > 0 ? 'income' : 'expense' const txSuggestions = templateSuggestions?.[currentTransaction.id] return (

Välj mall

{currentTransaction.description}

{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}

{error && (
{error}
)}
) } if (showReviewStep && pendingCategory) { const categoryLabel = [...expenseCategories, ...incomeCategories].find( (c) => c.value === pendingCategory )?.label || pendingCategory const selectedTemplate = pendingTemplateId ? getTemplateById(pendingTemplateId) : null // Auto-clear VAT when a class 2 (liability/equity) account is selected const isLiabilityAccount = accountOverride.startsWith('2') return (

Granska bokföring

{/* Transaction summary */}

{currentTransaction.description}

{formatDate(currentTransaction.date)}

{currentTransaction.amount > 0 ? '+' : ''} {formatCurrency(currentTransaction.amount, currentTransaction.currency)}

{/* Selected template or category */}
{selectedTemplate ? selectedTemplate.name_sv : categoryLabel}
{/* Template special rules warning */} {selectedTemplate?.special_rules_sv && (

{selectedTemplate.special_rules_sv}

)} {/* Deductibility note */} {selectedTemplate?.deductibility_note_sv && (

{selectedTemplate.deductibility_note_sv}

)} {/* Reverse charge VAT registration warning */} {selectedTemplate?.requires_vat_registration_data && (

Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.

)} {/* Journal entry preview */} {/* Account override */}
{/* VAT treatment */}
{isLiabilityAccount ? (

Ingen moms för skuld-/eget kapital-konton

) : showVatDropdown ? ( ) : (

{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'} {' '}

)}
{/* Document upload / pre-attached document */} {pendingInboxItemId && currentTransaction.matched_inbox_item?.document_id ? (
Underlag bifogat

Dokumentet från inkorgen länkas automatiskt till verifikationen.

) : (
{showUploadZone && (
)}
)} {error && (
{error}
)}
{/* Actions */}
) } return (
{/* Header */}

{currentIndex + 1} av {transactions.length}

{/* Instructions */}
Hoppa över
Bokför
{/* Card stack */}
{/* Swipe indicators */} Hoppa över Företag
{formatDate(currentTransaction.date)}
{currentTransaction.receipt_id && ( Kvitto )} {currentTransaction.currency}

{currentTransaction.description}

0 ? 'text-success' : '' }`} > {currentTransaction.amount > 0 ? '+' : ''} {formatCurrency(currentTransaction.amount, currentTransaction.currency)}

{/* Potential Invoice Match */} {currentTransaction.potential_invoice && (
Fakturamatchning hittad
Match

Faktura {currentTransaction.potential_invoice.invoice_number}

{currentTransaction.potential_invoice.customer?.name || 'Okänd kund'}

{formatCurrency( currentTransaction.potential_invoice.total, currentTransaction.potential_invoice.currency )}

)} {/* Document Match from Inbox */} {currentTransaction.matched_inbox_item && (
{currentTransaction.matched_inbox_item.document_type === 'receipt' ? 'Matchat kvitto' : currentTransaction.matched_inbox_item.document_type === 'supplier_invoice' ? 'Matchad leverantörsfaktura' : 'Matchat dokument'}
{currentTransaction.matched_inbox_item.match_confidence != null && ( {Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}% )}
{(() => { const ext = currentTransaction.matched_inbox_item.extracted_data as Record | null if (!ext) return null const supplierName = (ext as { supplier?: { name?: string } })?.supplier?.name const merchantName = (ext as { merchant?: { name?: string } })?.merchant?.name const totals = ext as { totals?: { total?: number } } return ( <> {(supplierName || merchantName) && (

{supplierName || merchantName}

)} {totals?.totals?.total != null && (

{formatCurrency(totals.totals.total)}

)} ) })()} {currentTransaction.matched_inbox_item.suggested_template_id && (

Mall: {currentTransaction.matched_inbox_item.suggested_template_id}

)}
)} {/* Warnings */} {warnings.length > 0 && (
{warnings.map((warning, idx) => (

{warning.category}

{warning.message}

))}
)}
{/* Action buttons */}
{/* Error message */} {error && (
{error}
)} {/* Document template match — primary action when inbox item has a suggested template */} {currentTransaction.matched_inbox_item?.suggested_template_id && (() => { const tmplId = currentTransaction.matched_inbox_item!.suggested_template_id! const template = getTemplateById(tmplId) if (!template) return null return ( ) })()} {/* Invoice match button - primary action when there's a match */} {currentTransaction.potential_invoice && onMatchInvoice && ( )} {/* Suggested categories - shown as quick-select buttons */} {suggestions && suggestions[currentTransaction.id] && suggestions[currentTransaction.id].length > 0 && (

Föreslagna kategorier

{suggestions[currentTransaction.id].map((suggestion) => ( ))}
)} {/* Describe transaction button — only when AI categorization is enabled */} {ENABLED_EXTENSION_IDS.has('ai-categorization') && ( )} {/* Categorization button */} {/* Skip button - always visible */}
{ setShowDescribeDialog(false) moveToNext() }} onBatchApplied={() => { setShowDescribeDialog(false) moveToNext() }} />
) }