cad83d180d
* fix(currency): use transaction-date FX rate and post mall bookings in SEK (#442) Three related defects in non-SEK transaction handling: 1. Ingest priced every transaction in a batch at today's rate because fetchMultipleRates() was called without a date. Switch to a per- (currency, date) fetch via fetchExchangeRate() + Promise.allSettled, so each row stores Riksbanken's rate as of its own transaction date. 2. buildMappingResultFromTemplate emitted VAT/reverse-charge/output-VAT lines from Math.abs(transaction.amount) — the source-currency amount. Mall bookings on USD/EUR transactions therefore posted the journal entry in the wrong currency. Resolve to SEK once via resolveSekAmount() and reuse for every line, matching what createTransactionJournalEntry already does for the settlement line. 3. The Granska bokföring modal and the journal-entry detail page lacked a clear audit trail. The verifikation preview now renders in SEK with a separate original-currency subtitle in the header; the detail page gains a dedicated "Valutaomräkning" card above Kontorader showing the rate and original total. Also: replace truncate with break-all on the modal description so long UUID-style strings (e.g. BALANCE_CASHBACK-…) wrap instead of forcing horizontal overscroll. Tests: 4 new booking-templates cases (USD 25%, EUR fallback via exchange_rate, EU reverse-charge on USD, output VAT on USD income) and 2 new ingest cases (per-pair fetch dedup; no fetch for SEK rows). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(currency): wire SEK conversion into SwipeCategorizationView (#442 follow-up) Address Greptile review: - SwipeCategorizationView rendered JournalEntryPreview without amountSek, so the swipe flow had the same defect QuickReviewDialog had — a USD transaction showed the verifikation in source currency labeled SEK. Now resolves SEK once via resolveSekAmount, passes it to the preview, and shows SEK as the headline with the original-currency subtitle (parallel to QuickReviewDialog). - Drop the now-dead `currency` prop from JournalEntryPreviewProps and its remaining call site in QuickReviewDialog — the component hardcodes 'SEK' on display, so the prop was misleading. - Correct the misleading inline comment in ingest.ts: network failures resolve inside fetchExchangeRate to getFallbackRate() (non-null), so the key only stays unset on empty-observation responses or outright rejections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
840 lines
31 KiB
TypeScript
840 lines
31 KiB
TypeScript
'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 { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
|
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
|
|
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 } from 'lucide-react'
|
|
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 type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
|
|
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
|
|
|
interface SwipeCategorizationViewProps {
|
|
transactions: TransactionWithInvoice[]
|
|
suggestions?: Record<string, SuggestedCategory[]>
|
|
templateSuggestions?: Record<string, SuggestedTemplate[]>
|
|
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<string | null>(null)
|
|
|
|
// Review step state
|
|
const [showReviewStep, setShowReviewStep] = useState(false)
|
|
const [pendingCategory, setPendingCategory] = useState<TransactionCategory | null>(null)
|
|
const [accountOverride, setAccountOverride] = useState('')
|
|
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
|
|
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
|
const [showUploadZone, setShowUploadZone] = useState(false)
|
|
|
|
const [showVatDropdown, setShowVatDropdown] = useState(false)
|
|
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null)
|
|
const [pendingTemplate, setPendingTemplate] = useState<BookingTemplate | null>(null)
|
|
const [pendingInboxItemId, setPendingInboxItemId] = useState<string | null>(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) {
|
|
setAccounts(data)
|
|
}
|
|
} 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)
|
|
setPendingTemplate(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')
|
|
// Library templates aren't in the static registry the backend validates against,
|
|
// so we only send the ID for static templates. The pre-filled account/VAT drive
|
|
// the booking for library templates.
|
|
setPendingTemplateId(isLibraryTemplateId(template.id) ? null : template.id)
|
|
setPendingTemplate(template)
|
|
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)
|
|
setPendingTemplate(template)
|
|
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)
|
|
setPendingTemplate(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 (
|
|
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
|
|
<Check className="h-8 w-8 text-success" />
|
|
</div>
|
|
<h2 className="font-display text-2xl font-medium">Klart!</h2>
|
|
<p className="text-muted-foreground mt-2">
|
|
Alla transaktioner är nu bokförda
|
|
</p>
|
|
<Button onClick={onClose} className="mt-6">
|
|
Tillbaka till transaktioner
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (showCategorySelect) {
|
|
const direction = currentTransaction.amount > 0 ? 'income' : 'expense'
|
|
const txSuggestions = templateSuggestions?.[currentTransaction.id]
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-background z-50 flex flex-col">
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<Button variant="ghost" size="icon" onClick={() => setShowCategorySelect(false)}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<h1 className="font-semibold">Välj mall</h1>
|
|
<div className="w-10" />
|
|
</div>
|
|
|
|
<Card className="mx-4 mt-3">
|
|
<CardContent className="pt-4">
|
|
<p className="font-medium">{currentTransaction.description}</p>
|
|
<p className="font-display text-2xl font-medium tabular-nums mt-2">
|
|
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && (
|
|
<div className="mx-4 mt-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-hidden mt-2">
|
|
<TemplatePicker
|
|
direction={direction}
|
|
entityType={entityType}
|
|
suggestedTemplates={txSuggestions}
|
|
onSelect={handlePickerTemplateSelect}
|
|
selectedTemplateId={pendingTemplate?.id ?? pendingTemplateId ?? undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div className="p-4 border-t">
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full text-muted-foreground"
|
|
onClick={handleSkip}
|
|
>
|
|
<SkipForward className="mr-2 h-4 w-4" />
|
|
Hoppa över
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (showReviewStep && pendingCategory) {
|
|
const categoryLabel = [...expenseCategories, ...incomeCategories].find(
|
|
(c) => c.value === pendingCategory
|
|
)?.label || pendingCategory
|
|
const selectedTemplate = pendingTemplate
|
|
|
|
// Auto-clear VAT when a class 2 (liability/equity) account is selected
|
|
const isLiabilityAccount = accountOverride.startsWith('2')
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-background z-50 flex flex-col">
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setShowReviewStep(false)
|
|
setShowCategorySelect(true)
|
|
}}
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<h1 className="font-semibold">Granska bokföring</h1>
|
|
<div className="w-10" />
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
|
{/* Transaction summary */}
|
|
{(() => {
|
|
const reviewSekAmount = resolveSekAmount(
|
|
currentTransaction.amount,
|
|
currentTransaction.amount_sek,
|
|
currentTransaction.currency,
|
|
currentTransaction.exchange_rate
|
|
)
|
|
const isForeign = !!(currentTransaction.currency && currentTransaction.currency !== 'SEK')
|
|
return (
|
|
<Card>
|
|
<CardContent className="pt-4 space-y-1">
|
|
<p className="font-medium break-all">{currentTransaction.description}</p>
|
|
<p className="text-sm text-muted-foreground">{formatDate(currentTransaction.date)}</p>
|
|
<p className="font-display text-2xl font-medium tabular-nums mt-2">
|
|
{currentTransaction.amount > 0 ? '+' : ''}
|
|
{formatCurrency(reviewSekAmount, 'SEK')}
|
|
</p>
|
|
{isForeign && (
|
|
<p className="text-xs text-muted-foreground tabular-nums">
|
|
{currentTransaction.amount > 0 ? '+' : ''}
|
|
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})()}
|
|
|
|
{/* Selected template or category */}
|
|
<div>
|
|
<label className="text-sm font-medium text-muted-foreground">
|
|
{selectedTemplate ? 'Mall' : 'Kategori'}
|
|
</label>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Badge variant="outline" className="text-sm py-1 px-3">
|
|
{selectedTemplate ? selectedTemplate.name_sv : categoryLabel}
|
|
</Badge>
|
|
<button
|
|
type="button"
|
|
className="text-xs text-primary hover:underline"
|
|
onClick={() => {
|
|
setShowReviewStep(false)
|
|
setShowCategorySelect(true)
|
|
}}
|
|
>
|
|
Byt mall
|
|
</button>
|
|
</div>
|
|
{selectedTemplate && (
|
|
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
|
|
D: {formatAccountWithName(selectedTemplate.debit_account)} → K: {formatAccountWithName(selectedTemplate.credit_account)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Template special rules warning */}
|
|
{selectedTemplate?.special_rules_sv && (
|
|
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2.5">
|
|
<p className="text-xs text-warning-foreground leading-snug">
|
|
{selectedTemplate.special_rules_sv}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deductibility note */}
|
|
{selectedTemplate?.deductibility_note_sv && (
|
|
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-3 py-2.5">
|
|
<p className="text-xs text-foreground leading-snug">
|
|
{selectedTemplate.deductibility_note_sv}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reverse charge VAT registration warning */}
|
|
{selectedTemplate?.requires_vat_registration_data && (
|
|
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2.5">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
|
|
<p className="text-xs text-warning-foreground leading-snug">
|
|
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Journal entry preview */}
|
|
<JournalEntryPreview
|
|
amount={currentTransaction.amount}
|
|
amountSek={resolveSekAmount(
|
|
currentTransaction.amount,
|
|
currentTransaction.amount_sek,
|
|
currentTransaction.currency,
|
|
currentTransaction.exchange_rate
|
|
)}
|
|
category={pendingCategory}
|
|
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
|
|
accountOverride={accountOverride}
|
|
entityType={entityType}
|
|
/>
|
|
|
|
{/* Account override */}
|
|
<div>
|
|
<label className="text-sm font-medium text-muted-foreground">Konto</label>
|
|
<div className="mt-1">
|
|
<AccountCombobox
|
|
value={accountOverride}
|
|
accounts={accounts}
|
|
onChange={setAccountOverride}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* VAT treatment */}
|
|
<div>
|
|
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
|
|
<div className="mt-1">
|
|
{isLiabilityAccount ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Ingen moms för skuld-/eget kapital-konton
|
|
</p>
|
|
) : showVatDropdown ? (
|
|
<VatTreatmentSelect
|
|
value={vatTreatment}
|
|
onValueChange={setVatTreatment}
|
|
/>
|
|
) : (
|
|
<p className="text-sm">
|
|
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
|
|
{' '}
|
|
<button
|
|
type="button"
|
|
className="text-xs text-primary hover:underline"
|
|
onClick={() => setShowVatDropdown(true)}
|
|
>
|
|
Ändra
|
|
</button>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Document upload */}
|
|
{(
|
|
<div className="rounded-lg border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowUploadZone(!showUploadZone)}
|
|
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium">Underlag</span>
|
|
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
|
</span>
|
|
)}
|
|
</div>
|
|
{showUploadZone ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
{showUploadZone && (
|
|
<div className="px-3 pb-3">
|
|
<DocumentUploadZone
|
|
files={uploadedFiles}
|
|
onFilesChange={setUploadedFiles}
|
|
compact
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="p-4 border-t space-y-2">
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleReviewConfirm}
|
|
disabled={isProcessing || !accountOverride}
|
|
>
|
|
<Check className="mr-2 h-4 w-4" />
|
|
{isProcessing ? 'Bokför...' : 'Bokför'}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full text-muted-foreground"
|
|
onClick={handleSkip}
|
|
disabled={isProcessing}
|
|
>
|
|
<SkipForward className="mr-2 h-4 w-4" />
|
|
Hoppa över
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-background z-50 flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top, 0px)', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
<div className="text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{currentIndex + 1} av {transactions.length}
|
|
</p>
|
|
</div>
|
|
<div className="w-10" />
|
|
</div>
|
|
|
|
{/* Instructions */}
|
|
<div className="flex justify-between px-8 py-4 text-sm">
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
<SkipForward className="h-4 w-4" />
|
|
<span>Hoppa över</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
<span>Bokför</span>
|
|
<Building className="h-4 w-4" />
|
|
<ArrowRight className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card stack */}
|
|
<div className="flex-1 flex items-center justify-center px-4 relative">
|
|
{/* Swipe indicators */}
|
|
<motion.div
|
|
className="absolute left-8 flex items-center gap-2 text-muted-foreground"
|
|
style={{ opacity: skipIndicatorOpacity }}
|
|
>
|
|
<SkipForward className="h-8 w-8" />
|
|
<span className="font-semibold">Hoppa över</span>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="absolute right-8 flex items-center gap-2 text-success"
|
|
style={{ opacity: businessIndicatorOpacity }}
|
|
>
|
|
<span className="font-semibold">Företag</span>
|
|
<Building className="h-8 w-8" />
|
|
</motion.div>
|
|
|
|
<AnimatePresence>
|
|
<motion.div
|
|
key={currentTransaction.id}
|
|
drag="x"
|
|
dragConstraints={{ left: 0, right: 0 }}
|
|
dragElastic={1}
|
|
onDrag={handleDrag}
|
|
onDragEnd={handleDragEnd}
|
|
style={{ x, rotate, opacity }}
|
|
className="w-full max-w-sm touch-none cursor-grab active:cursor-grabbing"
|
|
>
|
|
<Card className="shadow-lg">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="outline">{formatDate(currentTransaction.date)}</Badge>
|
|
<div className="flex items-center gap-2">
|
|
{currentTransaction.receipt_id && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<ReceiptIcon className="h-3 w-3" />
|
|
Kvitto
|
|
</Badge>
|
|
)}
|
|
{currentTransaction.document_id && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<Paperclip className="h-3 w-3" />
|
|
Bilaga
|
|
</Badge>
|
|
)}
|
|
<Badge>{currentTransaction.currency}</Badge>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-lg font-medium">{currentTransaction.description}</p>
|
|
|
|
<p
|
|
className={`text-3xl font-bold ${
|
|
currentTransaction.amount > 0 ? 'text-success' : ''
|
|
}`}
|
|
>
|
|
{currentTransaction.amount > 0 ? '+' : ''}
|
|
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
|
|
</p>
|
|
|
|
{/* Potential Invoice Match */}
|
|
{currentTransaction.potential_invoice && (
|
|
<div className="p-4 rounded-lg border-2 border-success/40 bg-success/5 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-success">
|
|
<FileText className="h-5 w-5" />
|
|
<span className="font-semibold text-sm">Fakturamatchning hittad</span>
|
|
</div>
|
|
<Badge variant="outline" className="text-success border-success">
|
|
Match
|
|
</Badge>
|
|
</div>
|
|
<div className="text-sm">
|
|
<p className="font-medium">
|
|
Faktura {currentTransaction.potential_invoice.invoice_number}
|
|
</p>
|
|
<p className="text-muted-foreground">
|
|
{currentTransaction.potential_invoice.customer?.name || 'Okänd kund'}
|
|
</p>
|
|
<p className="font-medium text-success">
|
|
{formatCurrency(
|
|
currentTransaction.potential_invoice.total,
|
|
currentTransaction.potential_invoice.currency
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Warnings */}
|
|
{warnings.length > 0 && (
|
|
<div className="space-y-2 pt-4 border-t">
|
|
{warnings.map((warning, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={`flex items-start gap-2 p-2 rounded text-sm ${
|
|
warning.warningLevel === 'danger'
|
|
? 'bg-destructive/10 text-destructive'
|
|
: warning.warningLevel === 'warning'
|
|
? 'bg-warning/10 text-warning-foreground'
|
|
: 'bg-muted'
|
|
}`}
|
|
>
|
|
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium">{warning.category}</p>
|
|
<p className="text-xs opacity-90">{warning.message}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="p-4 border-t">
|
|
<div className="flex flex-col gap-3 max-w-sm mx-auto">
|
|
{/* Error message */}
|
|
{error && (
|
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Invoice match button - primary action when there's a match */}
|
|
{currentTransaction.potential_invoice && onMatchInvoice && (
|
|
<Button
|
|
className="w-full bg-success hover:bg-success/90 text-success-foreground"
|
|
onClick={handleMatchInvoice}
|
|
disabled={isProcessing}
|
|
>
|
|
<Link2 className="mr-2 h-4 w-4" />
|
|
Matcha med Faktura {currentTransaction.potential_invoice.invoice_number}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Suggested categories - shown as quick-select buttons */}
|
|
{suggestions && suggestions[currentTransaction.id] && suggestions[currentTransaction.id].length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground text-center">Föreslagna kategorier</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{suggestions[currentTransaction.id].map((suggestion) => (
|
|
<Button
|
|
key={suggestion.category}
|
|
variant="outline"
|
|
className="h-auto py-2.5 px-3 text-left justify-start border-primary/30 hover:border-primary hover:bg-primary/5"
|
|
onClick={() => handleCategorySelect(suggestion.category)}
|
|
disabled={isProcessing}
|
|
>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium">{suggestion.label}</span>
|
|
{suggestion.account && (
|
|
<span className="text-xs text-muted-foreground">{formatAccountWithName(suggestion.account)}</span>
|
|
)}
|
|
</div>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Categorization button */}
|
|
<Button
|
|
className="w-full"
|
|
onClick={() => {
|
|
if (currentTransaction.amount < 0) {
|
|
setShowCategorySelect(true)
|
|
} else {
|
|
handleCategorySelect('income_other')
|
|
}
|
|
}}
|
|
disabled={isProcessing}
|
|
>
|
|
<Building className="mr-2 h-4 w-4" />
|
|
Bokför
|
|
</Button>
|
|
|
|
{/* Skip button - always visible */}
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full text-muted-foreground"
|
|
onClick={handleSkip}
|
|
disabled={isProcessing}
|
|
>
|
|
<SkipForward className="mr-2 h-4 w-4" />
|
|
Hoppa över
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
)
|
|
}
|