-
-
-
- (
-
- )}
- />
-
- {watchedCurrency !== 'SEK' && (
-
-
-
-
- )}
-
-
- (
-
- )}
- />
-
-
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index f0198632..b13d3927 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -1,7 +1,8 @@
'use client'
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import { AnimatePresence } from 'framer-motion'
+import { useSearchParams } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -123,6 +124,11 @@ export default function TransactionsPage() {
const { toast } = useToast()
const { dialogProps: deleteDialogProps, confirm: confirmDelete } = useDestructiveConfirm()
const supabase = createClient()
+ const searchParams = useSearchParams()
+ const highlightId = searchParams.get('highlight')
+ // Tracks the last highlight target we acted on so re-renders don't re-trigger
+ // the auto-open every time the user closes the categorize panel.
+ const handledHighlightRef = useRef(null)
// Computed lists
const uncategorizedTransactions = transactions
@@ -293,6 +299,35 @@ export default function TransactionsPage() {
return () => { cancelled = true }
}, [])
+ // Auto-open categorize panel when arriving via /transactions?highlight=
+ // (used by the inbox "Bokför transaktionen" link). Runs once per distinct
+ // highlight id so closing the panel doesn't re-trigger it.
+ useEffect(() => {
+ if (!highlightId) return
+ if (handledHighlightRef.current === highlightId) return
+ if (transactions.length === 0) return
+ const tx = transactions.find((t) => t.id === highlightId)
+ if (!tx) return
+ handledHighlightRef.current = highlightId
+
+ // Defer the scroll until React has committed the list to the DOM.
+ // Without rAF the data-tx-id node may not exist yet when this fires
+ // immediately after fetchTransactions resolves.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ const el = document.querySelector(`[data-tx-id="${tx.id}"]`)
+ if (el && 'scrollIntoView' in el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+ })
+ })
+
+ if (tx.is_business === null && !tx.journal_entry_id) {
+ setTemplatePickerTransaction(tx)
+ setTemplatePickerOpen(true)
+ }
+ }, [highlightId, transactions])
+
// Auto-fetch suggestions when transactions load
useEffect(() => {
const uncatIds = transactions
diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts
index a0691121..f4970fe5 100644
--- a/app/api/reports/kpi/route.ts
+++ b/app/api/reports/kpi/route.ts
@@ -51,19 +51,32 @@ export async function GET(request: Request) {
(prefsData?.value as Partial) ?? {}
)
- const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown, paidInvoicesResult] =
- await Promise.all([
- generateIncomeStatement(supabase, companyId, periodId),
- generateTrialBalance(supabase, companyId, periodId),
- generateARLedger(supabase, companyId),
- generateMonthlyBreakdown(supabase, companyId, periodId),
- supabase
- .from('invoices')
- .select('invoice_date, paid_at')
- .eq('company_id', companyId)
- .eq('status', 'paid')
- .not('paid_at', 'is', null),
- ])
+ const [
+ incomeStatement,
+ trialBalanceResult,
+ arLedger,
+ monthlyBreakdown,
+ paidInvoicesResult,
+ topSuppliersResult,
+ ] = await Promise.all([
+ generateIncomeStatement(supabase, companyId, periodId),
+ generateTrialBalance(supabase, companyId, periodId),
+ generateARLedger(supabase, companyId),
+ generateMonthlyBreakdown(supabase, companyId, periodId),
+ supabase
+ .from('invoices')
+ .select('invoice_date, paid_at')
+ .eq('company_id', companyId)
+ .eq('status', 'paid')
+ .not('paid_at', 'is', null),
+ supabase
+ .from('supplier_invoices')
+ .select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
+ .eq('company_id', companyId)
+ .gte('invoice_date', period.period_start)
+ .lte('invoice_date', period.period_end)
+ .neq('status', 'credited'),
+ ])
// Cash position — use account overrides if set
const cashOverrides = preferences.accountOverrides['cashPosition']
@@ -108,6 +121,59 @@ export async function GET(request: Request) {
paid_at: inv.paid_at as string,
}))
+ // Expense composition by BAS class (4-7). Expense accounts have a debit
+ // normal balance, so amount = closing_debit - closing_credit. Negative
+ // values (rare reclassifications) are clamped to 0 so the donut renders
+ // sensibly.
+ const expenseComposition = trialBalanceResult.rows.reduce(
+ (acc, r) => {
+ if (r.account_class < 4 || r.account_class > 7) return acc
+ const amount = r.closing_debit - r.closing_credit
+ if (amount <= 0) return acc
+ if (r.account_class === 4) acc.class4 += amount
+ else if (r.account_class === 5) acc.class5 += amount
+ else if (r.account_class === 6) acc.class6 += amount
+ else if (r.account_class === 7) acc.class7 += amount
+ return acc
+ },
+ { class4: 0, class5: 0, class6: 0, class7: 0 }
+ )
+
+ // Top suppliers by spend within the fiscal period. Sum total_sek to avoid
+ // mixing currencies. Drop FX invoices without a SEK conversion (total_sek
+ // null) — they would otherwise inflate a supplier's total with raw
+ // foreign-currency amounts.
+ type SupplierInvoiceRow = {
+ supplier_id: string | null
+ total_sek: number | null
+ total: number | null
+ supplier: { id: string; name: string } | { id: string; name: string }[] | null
+ }
+ if (topSuppliersResult.error) {
+ // Surface the failure rather than silently rendering an empty chart that
+ // matches the legitimate "no supplier invoices" empty state.
+ console.error('[kpi] topSuppliersResult error:', topSuppliersResult.error)
+ }
+ const supplierTotals = new Map()
+ for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
+ if (!row.supplier_id) continue
+ const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
+ if (!supplier?.name) continue
+ const amount = row.total_sek ?? null
+ if (amount == null) continue
+ const existing = supplierTotals.get(row.supplier_id)
+ if (existing) existing.total += amount
+ else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
+ }
+ const topSuppliers = Array.from(supplierTotals.entries())
+ .map(([supplier_id, v]) => ({
+ supplier_id,
+ supplier_name: v.name,
+ total: Math.round(v.total * 100) / 100,
+ }))
+ .sort((a, b) => b.total - a.total)
+ .slice(0, 7)
+
const report: KPIReport = {
netResult: incomeStatement.net_result,
cashPosition,
@@ -122,6 +188,13 @@ export async function GET(request: Request) {
periodComplete: period.is_closed,
months: monthlyBreakdown.months,
period: { start: period.period_start, end: period.period_end },
+ expenseComposition: {
+ class4: Math.round(expenseComposition.class4 * 100) / 100,
+ class5: Math.round(expenseComposition.class5 * 100) / 100,
+ class6: Math.round(expenseComposition.class6 * 100) / 100,
+ class7: Math.round(expenseComposition.class7 * 100) / 100,
+ },
+ topSuppliers,
}
return NextResponse.json({ data: report })
diff --git a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts
index 95a1cb14..c0cbdf9b 100644
--- a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts
@@ -82,7 +82,8 @@ describe('POST /api/transactions/[id]/attach-document', () => {
it('attaches when both rows exist', async () => {
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
- enqueue({ data: null, error: null }) // update
+ enqueue({ data: null, error: null }) // transactions update
+ enqueue({ data: null, error: null }) // inbox-link best-effort update
const res = await POST(
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
createMockRouteParams({ id: 'tx-1' }),
@@ -92,6 +93,41 @@ describe('POST /api/transactions/[id]/attach-document', () => {
expect(body.data.transaction_id).toBe('tx-1')
expect(body.data.document_id).toBe('11111111-1111-4111-8111-111111111111')
})
+
+ it('attempts to update invoice_inbox_items.matched_transaction_id after successful attach', async () => {
+ // The side effect lets the inbox UI flip an item from "needs action" to
+ // "Kopplad till transaktion" without an extra round-trip.
+ enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
+ enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
+ enqueue({ data: null, error: null }) // transactions update
+ enqueue({ data: null, error: null }) // inbox-link update
+
+ await POST(
+ makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
+ createMockRouteParams({ id: 'tx-1' }),
+ )
+ // Verify the inbox_items table was touched.
+ const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0])
+ expect(fromCalls).toContain('invoice_inbox_items')
+ })
+
+ it('tolerates a failing inbox-link update — the document attach is the primary effect', async () => {
+ enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
+ enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
+ enqueue({ data: null, error: null }) // transactions update
+ enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails
+
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const res = await POST(
+ makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
+ createMockRouteParams({ id: 'tx-1' }),
+ )
+ const { status, body } = await parseJsonResponse<{ data: { transaction_id: string } }>(res)
+ // Side-effect failure must not roll back the (compliant) document attach.
+ expect(status).toBe(200)
+ expect(body.data.transaction_id).toBe('tx-1')
+ spy.mockRestore()
+ })
})
describe('DELETE /api/transactions/[id]/attach-document', () => {
diff --git a/app/api/transactions/[id]/attach-document/route.ts b/app/api/transactions/[id]/attach-document/route.ts
index b7b5a02a..f2d41313 100644
--- a/app/api/transactions/[id]/attach-document/route.ts
+++ b/app/api/transactions/[id]/attach-document/route.ts
@@ -83,6 +83,22 @@ export async function POST(
return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 })
}
+ // If this document came from an invoice_inbox_items row, mark that row
+ // as matched so the inbox UI can show it as "Kopplad" + link back to the
+ // transaction. Best-effort: a failure here must not roll back the
+ // (compliant) document attach.
+ try {
+ await supabase
+ .from('invoice_inbox_items')
+ .update({ matched_transaction_id: transactionId })
+ .eq('document_id', document_id)
+ .eq('company_id', companyId)
+ .is('matched_transaction_id', null)
+ .is('created_supplier_invoice_id', null)
+ } catch (linkErr) {
+ console.error('[attach-document] Failed to link inbox item:', linkErr)
+ }
+
// Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc
// was replaced. Best-effort — a logging failure must not roll back the
// (compliant) attach.
diff --git a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts
new file mode 100644
index 00000000..d5c8bc95
--- /dev/null
+++ b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts
@@ -0,0 +1,198 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import {
+ createQueuedMockSupabase,
+ createMockRouteParams,
+ parseJsonResponse,
+} from '@/tests/helpers'
+
+const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: () => Promise.resolve(mockSupabase),
+}))
+
+vi.mock('@/lib/init', () => ({
+ ensureInitialized: vi.fn(),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
+}))
+
+vi.mock('@/lib/invoices/match-log', () => ({
+ logMatchEvent: vi.fn(),
+}))
+
+vi.mock('@/lib/events/bus', () => ({
+ eventBus: { emit: vi.fn() },
+}))
+
+const mockCreatePaymentEntry = vi.fn()
+const mockCreateCashEntry = vi.fn()
+vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
+ createSupplierInvoicePaymentEntry: (...args: unknown[]) => mockCreatePaymentEntry(...args),
+ createSupplierInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
+}))
+
+import { POST } from '../route'
+
+const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
+ mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
+ mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
+})
+
+const TX_UUID = '11111111-1111-4111-8111-111111111111'
+const SI_UUID = '22222222-2222-4222-8222-222222222222'
+
+function makeReq() {
+ return new Request(`http://localhost/api/transactions/${TX_UUID}/match-supplier-invoice`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ supplier_invoice_id: SI_UUID }),
+ })
+}
+
+function enqueueHappyPath(opts: {
+ transaction: { amount: number; currency: string; amount_sek?: number | null }
+ invoice: {
+ currency: string
+ exchange_rate?: number | null
+ remaining_amount?: number
+ paid_amount?: number
+ }
+}) {
+ // 1. transactions fetch
+ enqueue({
+ data: {
+ id: TX_UUID,
+ company_id: 'company-1',
+ amount: opts.transaction.amount,
+ currency: opts.transaction.currency,
+ amount_sek: opts.transaction.amount_sek ?? null,
+ supplier_invoice_id: null,
+ date: '2026-05-12',
+ },
+ error: null,
+ })
+ // 2. supplier_invoices fetch
+ enqueue({
+ data: {
+ id: SI_UUID,
+ currency: opts.invoice.currency,
+ exchange_rate: opts.invoice.exchange_rate ?? null,
+ status: 'registered',
+ remaining_amount: opts.invoice.remaining_amount ?? 225,
+ paid_amount: opts.invoice.paid_amount ?? 0,
+ supplier: { supplier_type: 'eu_business' },
+ items: [],
+ },
+ error: null,
+ })
+ // 3. company_settings fetch
+ enqueue({ data: { accounting_method: 'accrual' }, error: null })
+ // 4. supplier_invoices update (CAS)
+ enqueue({ data: [{ id: SI_UUID }], error: null })
+ // 5. supplier_invoice_payments insert
+ enqueue({ data: null, error: null })
+ // 6. transactions update (link)
+ enqueue({ data: null, error: null })
+}
+
+describe('POST /api/transactions/[id]/match-supplier-invoice — FX residual', () => {
+ it('passes no exchangeRateDifference for a SEK transaction paying a SEK invoice', async () => {
+ enqueueHappyPath({
+ transaction: { amount: -2390, currency: 'SEK' },
+ invoice: { currency: 'SEK', remaining_amount: 2390 },
+ })
+ await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
+ const args = mockCreatePaymentEntry.mock.calls[0]
+ // (supabase, companyId, userId, invoice, paymentAmountSek, paymentDate, exchangeRateDifference?)
+ expect(args[4]).toBe(2390) // paymentAmountSek = actual bank SEK
+ expect(args[6]).toBeUndefined() // no FX diff
+ })
+
+ it('computes a loss when the SEK paid exceeds the AP booked SEK (EUR invoice)', async () => {
+ // Invoice: 225 EUR @ rate 10.6254 → AP booked at 2390.72 SEK.
+ // Bank: paid 2400 SEK out of a SEK account.
+ // → diff = 2390.72 − 2400 = −9.28 (loss, debit 7960).
+ enqueueHappyPath({
+ transaction: { amount: -2400, currency: 'SEK' },
+ invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
+ })
+ await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ const args = mockCreatePaymentEntry.mock.calls[0]
+ expect(args[4]).toBeCloseTo(2390.72, 2) // paymentAmountSek = originalBookedSek
+ expect(args[6]).toBeCloseTo(-9.28, 2) // exchangeRateDifference (loss)
+ })
+
+ it('computes a gain when the SEK paid is less than the AP booked SEK', async () => {
+ // Invoice: 100 EUR @ rate 11 → AP booked at 1100 SEK.
+ // Bank: paid 1080 SEK (rate had dipped) → diff = 1100 − 1080 = +20 (gain → credit 3960).
+ enqueueHappyPath({
+ transaction: { amount: -1080, currency: 'SEK' },
+ invoice: { currency: 'EUR', exchange_rate: 11, remaining_amount: 100 },
+ })
+ await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ const args = mockCreatePaymentEntry.mock.calls[0]
+ expect(args[4]).toBeCloseTo(1100, 2)
+ expect(args[6]).toBeCloseTo(20, 2)
+ })
+
+ it('uses transaction.amount_sek for a foreign-currency bank transaction', async () => {
+ // Reverse case: SEK invoice for 1000 kr, paid from a EUR card that
+ // showed amount_sek = 1063 (rate had moved).
+ // → diff = 1000 − 1063 = −63 (loss).
+ enqueueHappyPath({
+ transaction: { amount: -100, currency: 'EUR', amount_sek: -1063 },
+ invoice: { currency: 'SEK', remaining_amount: 1000 },
+ })
+ await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ const args = mockCreatePaymentEntry.mock.calls[0]
+ // SEK invoice: originalBookedSek = remaining = 1000, FX diff = 1000 - 1063 = -63
+ expect(args[4]).toBe(1000)
+ expect(args[6]).toBeCloseTo(-63, 2)
+ })
+
+ it('falls back to bank SEK when the invoice has no exchange_rate on file', async () => {
+ // Foreign-currency invoice but exchange_rate is null on the row.
+ // Without a rate we can't compute the AP-booked SEK precisely, so we
+ // pass the actual bank SEK and skip the FX diff.
+ enqueueHappyPath({
+ transaction: { amount: -239, currency: 'SEK' },
+ invoice: { currency: 'USD', exchange_rate: null, remaining_amount: 25 },
+ })
+ await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ const args = mockCreatePaymentEntry.mock.calls[0]
+ expect(args[4]).toBe(239)
+ expect(args[6]).toBeUndefined()
+ })
+})
+
+describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths', () => {
+ it('returns 200 with the expected body shape on the happy path', async () => {
+ enqueueHappyPath({
+ transaction: { amount: -1000, currency: 'SEK' },
+ invoice: { currency: 'SEK', remaining_amount: 1000 },
+ })
+ const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ const { status, body } = await parseJsonResponse<{
+ success: boolean
+ paid_amount: number
+ remaining_amount: number
+ }>(res)
+ expect(status).toBe(200)
+ expect(body.success).toBe(true)
+ expect(body.paid_amount).toBe(1000)
+ expect(body.remaining_amount).toBe(0)
+ })
+})
diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts
index 08285256..366612dc 100644
--- a/app/api/transactions/[id]/match-supplier-invoice/route.ts
+++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts
@@ -79,7 +79,59 @@ export const POST = withRouteContext(
})
}
- const paymentAmount = Math.abs(transaction.amount)
+ const txAmountAbs = Math.abs(transaction.amount)
+
+ // Amount in the *invoice's* currency — used to update
+ // supplier_invoices.paid_amount/remaining_amount and the
+ // supplier_invoice_payments row (whose `currency` is the invoice's).
+ // When the bank transaction is in a different currency from the
+ // invoice (e.g. paying a USD invoice from a SEK account) we treat the
+ // match as a full payment of whatever remains, rather than storing the
+ // SEK number with the invoice's currency suffix — which would render
+ // as "Betalt 239 USD" on a 25 USD invoice.
+ const paymentAmountInvoiceCurrency =
+ transaction.currency === invoice.currency
+ ? txAmountAbs
+ : invoice.remaining_amount
+
+ // Actual SEK leaving the bank — what really moved out of 1930. For a
+ // SEK transaction this is just the absolute amount; for a foreign-
+ // currency transaction we use the SEK conversion stored at import.
+ const actualBankSek =
+ transaction.currency === 'SEK'
+ ? txAmountAbs
+ : (transaction.amount_sek != null
+ ? Math.abs(transaction.amount_sek)
+ : txAmountAbs)
+
+ // SEK value that's actually sitting on 2440 for this payment portion:
+ // - SEK invoice: face value = paymentAmountInvoiceCurrency
+ // - Non-SEK invoice w/ exchange_rate: portion × rate
+ // - Non-SEK invoice w/o exchange_rate: can't compute precisely; fall
+ // back to actualBankSek (no FX diff, plain SEK booking)
+ // FX diff hits 7960/3960 so 2440 clears cleanly instead of leaving a
+ // residual. Triggered whenever bank-paid SEK differs from booked SEK —
+ // happens for any currency mismatch (SEK→EUR, EUR→SEK, EUR→USD), not
+ // just non-SEK invoices.
+ const invoiceFxRate = invoice.exchange_rate ?? null
+ const originalBookedSek =
+ invoice.currency === 'SEK'
+ ? paymentAmountInvoiceCurrency
+ : invoiceFxRate && invoiceFxRate > 0
+ ? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
+ : actualBankSek
+
+ // Positive = gain (AP credited at more SEK than the bank actually paid).
+ // Negative = loss (bank paid more SEK than the AP we owed).
+ const exchangeRateDifference =
+ Math.round((originalBookedSek - actualBankSek) * 100) / 100
+
+ // `paymentAmountSek` is what we pass to the payment-entry builder. In
+ // the FX branch (non-zero exchangeRateDifference) it represents the
+ // ORIGINAL booked SEK on 2440; the builder then computes actualSekPaid
+ // as paymentAmountSek - exchangeRateDifference internally.
+ const paymentAmountSek = exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek
+
const now = new Date().toISOString()
const { data: settings } = await supabase
@@ -90,6 +142,23 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
+ // Cash method (kontantmetoden) collapses registration + payment into a
+ // single entry that credits 1930 at sum(expenses_SEK). It has no
+ // exchange_rate_difference path — if the actual bank SEK differs from
+ // the invoice's booked SEK, the 1930 credit won't match the bank
+ // transaction and we'd silently leave a reconciliation gap. Block the
+ // combination and ask the user to switch to accrual or do a manual JE.
+ if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
+ return errorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
+ requestId,
+ details: {
+ exchangeRateDifference,
+ invoiceCurrency: invoice.currency,
+ transactionCurrency: transaction.currency,
+ },
+ })
+ }
+
let journalEntryId: string | null = null
let journalEntryError: string | null = null
@@ -105,7 +174,8 @@ export const POST = withRouteContext(
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
supabase, companyId, user.id, invoice as SupplierInvoice,
- paymentAmount, transaction.date,
+ paymentAmountSek, transaction.date,
+ exchangeRateDifference !== 0 ? exchangeRateDifference : undefined,
)
if (journalEntry) journalEntryId = journalEntry.id
}
@@ -120,8 +190,8 @@ export const POST = withRouteContext(
}
}
- const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
- const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
+ const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmountInvoiceCurrency) * 100) / 100)
+ const newPaidAmount = Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
@@ -155,7 +225,7 @@ export const POST = withRouteContext(
company_id: companyId,
supplier_invoice_id,
payment_date: transaction.date,
- amount: paymentAmount,
+ amount: paymentAmountInvoiceCurrency,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
diff --git a/app/api/transactions/create-from-document/__tests__/route.test.ts b/app/api/transactions/create-from-document/__tests__/route.test.ts
new file mode 100644
index 00000000..17cb6310
--- /dev/null
+++ b/app/api/transactions/create-from-document/__tests__/route.test.ts
@@ -0,0 +1,234 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
+
+const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: () => Promise.resolve(mockSupabase),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
+}))
+
+vi.mock('@/lib/init', () => ({
+ ensureInitialized: vi.fn(),
+}))
+
+import { POST } from '../route'
+
+const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
+})
+
+const VALID_UUID = '11111111-1111-4111-8111-111111111111'
+
+function makeReq(body: unknown) {
+ return new Request('http://localhost/api/transactions/create-from-document', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+}
+
+function validBody(overrides: Partial<{
+ inbox_item_id: string
+ amount: number
+ transaction_date: string
+ description: string
+}> = {}) {
+ return {
+ inbox_item_id: VALID_UUID,
+ amount: -100,
+ transaction_date: '2026-05-12',
+ description: 'Test supplier · INV-001',
+ ...overrides,
+ }
+}
+
+describe('POST /api/transactions/create-from-document', () => {
+ it('returns 401 when not authenticated', async () => {
+ mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse(res)
+ expect(status).toBe(401)
+ expect(body).toEqual({ error: 'Unauthorized' })
+ })
+
+ it('returns 400 when the body is invalid', async () => {
+ const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(400)
+ })
+
+ it('returns 400 when amount is zero (schema refine)', async () => {
+ const res = await POST(makeReq(validBody({ amount: 0 })))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(400)
+ })
+
+ it('returns 404 when the inbox item is not in the user company', async () => {
+ enqueue({ data: null, error: null }) // inbox item lookup misses
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(404)
+ expect(body.error).toBe('Inbox item not found')
+ })
+
+ it('returns 409 when the inbox item is already matched to a transaction', async () => {
+ enqueue({
+ data: {
+ id: VALID_UUID,
+ document_id: 'doc-1',
+ matched_transaction_id: 'tx-existing',
+ created_supplier_invoice_id: null,
+ extracted_data: null,
+ },
+ error: null,
+ })
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(409)
+ expect(body.error).toMatch(/redan kopplad/)
+ })
+
+ it('returns 409 when the inbox item is already booked as a supplier invoice', async () => {
+ enqueue({
+ data: {
+ id: VALID_UUID,
+ document_id: 'doc-1',
+ matched_transaction_id: null,
+ created_supplier_invoice_id: 'si-existing',
+ extracted_data: null,
+ },
+ error: null,
+ })
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(409)
+ expect(body.error).toMatch(/redan bokförd/)
+ })
+
+ it('creates the transaction and links the inbox item on the happy path', async () => {
+ enqueue({
+ data: {
+ id: VALID_UUID,
+ document_id: 'doc-1',
+ matched_transaction_id: null,
+ created_supplier_invoice_id: null,
+ extracted_data: { invoice: { currency: 'EUR' } },
+ },
+ error: null,
+ })
+ enqueue({ data: { id: 'new-tx-1' }, error: null }) // insert
+ enqueue({ data: [{ id: VALID_UUID }], error: null }) // inbox update — one row affected
+
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse<{
+ data: { transaction_id: string; inbox_item_id: string; document_id: string }
+ }>(res)
+ expect(status).toBe(200)
+ expect(body.data.transaction_id).toBe('new-tx-1')
+ expect(body.data.document_id).toBe('doc-1')
+ })
+
+ it('returns 409 and rolls back the orphan when a concurrent request linked first', async () => {
+ // Race scenario: both requests pass the matched_transaction_id IS NULL
+ // read; both insert their own transaction. The losing UPDATE matches
+ // zero rows because the .is('matched_transaction_id', null) predicate
+ // no longer holds. We delete the orphan and 409.
+ enqueue({
+ data: {
+ id: VALID_UUID,
+ document_id: 'doc-1',
+ matched_transaction_id: null,
+ created_supplier_invoice_id: null,
+ extracted_data: null,
+ },
+ error: null,
+ })
+ enqueue({ data: { id: 'orphan-tx' }, error: null }) // insert succeeds
+ enqueue({ data: [], error: null }) // inbox update affects zero rows — lost the race
+ enqueue({ data: null, error: null }) // rollback delete of the orphan
+
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(409)
+ expect(body.error).toMatch(/parallell begäran/)
+ })
+
+ it('coerces an unrecognised extracted currency to SEK before insert', async () => {
+ // Defense-in-depth: the deterministic extractor can still emit garbage
+ // for malformed PDFs. We must not let arbitrary strings reach the
+ // transactions.currency column.
+ enqueue({
+ data: {
+ id: VALID_UUID,
+ document_id: null,
+ matched_transaction_id: null,
+ created_supplier_invoice_id: null,
+ extracted_data: { invoice: { currency: 'XYZ' } },
+ },
+ error: null,
+ })
+ enqueue({ data: { id: 'new-tx-3' }, error: null })
+ enqueue({ data: [{ id: VALID_UUID }], error: null })
+
+ const res = await POST(makeReq(validBody()))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(200)
+ })
+
+ it('returns 500 when the transaction insert fails', async () => {
+ enqueue({
+ data: {
+ id: VALID_UUID,
+ document_id: null,
+ matched_transaction_id: null,
+ created_supplier_invoice_id: null,
+ extracted_data: null,
+ },
+ error: null,
+ })
+ enqueue({ data: null, error: { message: 'db down' } }) // insert fails
+ // Silence the console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(500)
+ expect(body.error).toMatch(/Kunde inte skapa transaktion/)
+ spy.mockRestore()
+ })
+
+ it('tolerates a failed inbox-link update — transaction exists, surface inbox_link_failed', async () => {
+ enqueue({
+ data: {
+ id: VALID_UUID,
+ document_id: 'doc-1',
+ matched_transaction_id: null,
+ created_supplier_invoice_id: null,
+ extracted_data: null,
+ },
+ error: null,
+ })
+ enqueue({ data: { id: 'new-tx-2' }, error: null }) // insert ok
+ enqueue({ data: null, error: { message: 'rls' } }) // link update fails
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const res = await POST(makeReq(validBody()))
+ const { status, body } = await parseJsonResponse<{
+ data: { transaction_id: string; inbox_link_failed?: boolean }
+ }>(res)
+ expect(status).toBe(200)
+ expect(body.data.transaction_id).toBe('new-tx-2')
+ expect(body.data.inbox_link_failed).toBe(true)
+ spy.mockRestore()
+ })
+})
diff --git a/app/api/transactions/create-from-document/route.ts b/app/api/transactions/create-from-document/route.ts
new file mode 100644
index 00000000..24682b56
--- /dev/null
+++ b/app/api/transactions/create-from-document/route.ts
@@ -0,0 +1,139 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { ensureInitialized } from '@/lib/init'
+import { validateBody } from '@/lib/api/validate'
+import { CreateTransactionFromDocumentSchema } from '@/lib/api/schemas'
+import { requireCompanyId } from '@/lib/company/context'
+import { requireWritePermission } from '@/lib/auth/require-write'
+
+ensureInitialized()
+
+/**
+ * POST /api/transactions/create-from-document
+ *
+ * Creates an uncategorized manual bank transaction prefilled from an
+ * invoice_inbox_items row, then attaches the inbox item's document to it
+ * and links the inbox item to the new transaction. The user categorizes
+ * the new transaction through the normal /transactions flow (which routes
+ * through the bookkeeping engine and respects period locks, etc.).
+ *
+ * Use case: receipt in the inbox has no matching bank transaction
+ * (cash purchase, personal-card expense, missed sync).
+ */
+export async function POST(request: Request) {
+ const supabase = await createClient()
+
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ const writeCheck = await requireWritePermission(supabase, user.id)
+ if (!writeCheck.ok) return writeCheck.response
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const validation = await validateBody(request, CreateTransactionFromDocumentSchema)
+ if (!validation.success) return validation.response
+ const { inbox_item_id, amount, transaction_date, description } = validation.data
+
+ const { data: item, error: itemError } = await supabase
+ .from('invoice_inbox_items')
+ .select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data')
+ .eq('id', inbox_item_id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (itemError || !item) {
+ return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
+ }
+ if (item.matched_transaction_id) {
+ return NextResponse.json(
+ { error: 'Inkorgsposten är redan kopplad till en transaktion.' },
+ { status: 409 },
+ )
+ }
+ if (item.created_supplier_invoice_id) {
+ return NextResponse.json(
+ { error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' },
+ { status: 409 },
+ )
+ }
+
+ // Allowlist the currency — extracted_data.invoice.currency comes from the
+ // (deterministic, but still untrusted) PDF extractor, so an arbitrary
+ // string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to
+ // the transactions table and break later formatCurrency / journal-entry
+ // bookings (BFL 5 kap 6 §). Coerce anything outside the supported set
+ // to SEK; the user can change it manually on the transaction.
+ const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
+ const extractedCurrency = (
+ item.extracted_data as { invoice?: { currency?: string } } | null
+ )?.invoice?.currency
+ const currency =
+ extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency)
+ ? extractedCurrency
+ : 'SEK'
+
+ const { data: newTx, error: insertError } = await supabase
+ .from('transactions')
+ .insert({
+ company_id: companyId,
+ user_id: user.id,
+ date: transaction_date,
+ description,
+ amount,
+ currency,
+ category: 'uncategorized',
+ is_business: null,
+ import_source: 'manual',
+ document_id: item.document_id,
+ })
+ .select('id')
+ .single()
+
+ if (insertError || !newTx) {
+ console.error('[create-from-document] Failed to insert transaction:', insertError)
+ return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 })
+ }
+
+ // Concurrency guard: the .is('matched_transaction_id', null) predicate +
+ // the rows-affected check turn this into an optimistic-lock release. If
+ // two requests with the same inbox_item_id race past the earlier
+ // matched_transaction_id check, only the first UPDATE will match a row
+ // here. The loser's transaction insert is then an orphan we proactively
+ // delete so the user doesn't get a duplicate uncategorized row.
+ const { data: linked, error: linkError } = await supabase
+ .from('invoice_inbox_items')
+ .update({ matched_transaction_id: newTx.id })
+ .eq('id', inbox_item_id)
+ .eq('company_id', companyId)
+ .is('matched_transaction_id', null)
+ .select('id')
+
+ if (linkError) {
+ console.error('[create-from-document] Failed to link inbox item:', linkError)
+ // Transaction was created; surface a 200 with a warning so the user can
+ // still find it under Transaktioner — the inbox-link orphan is recoverable.
+ return NextResponse.json({
+ data: { transaction_id: newTx.id, inbox_link_failed: true },
+ })
+ }
+
+ if (!linked || linked.length === 0) {
+ // Lost a race — another concurrent request linked the inbox item first.
+ // Roll back our newly-created transaction (only safe because we own it
+ // and it has no journal_entry_id yet) and return 409 so the client can
+ // refetch and reuse the winning transaction instead of creating a dupe.
+ // Re-assert company_id on the delete (defence in depth — newTx.id is a
+ // fresh UUID from a company-scoped insert above, but scoping the rollback
+ // makes the invariant explicit).
+ await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId)
+ return NextResponse.json(
+ { error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' },
+ { status: 409 },
+ )
+ }
+
+ return NextResponse.json({
+ data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id },
+ })
+}
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index b694e0c3..6d30df5a 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -16,7 +16,7 @@ import {
Settings,
LogOut,
Upload,
- Calendar,
+ Inbox,
Menu,
X,
HelpCircle,
@@ -67,7 +67,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/kpi', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
- { href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
+ { href: '/e/general/invoice-inbox', label: 'Dokumentinkorg', icon: Inbox, group: 'main', betaBadge: true },
// AR — Accounts Receivable
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'försäljning' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'försäljning' },
diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx
index aa0d1bec..2964f9b5 100644
--- a/components/extensions/general/InvoiceInboxWorkspace.tsx
+++ b/components/extensions/general/InvoiceInboxWorkspace.tsx
@@ -3,9 +3,11 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
+import { ToastAction } from '@/components/ui/toast'
import {
Inbox,
Upload,
@@ -20,8 +22,12 @@ import {
ArrowRight,
Plus,
Link2,
+ Search,
+ Circle,
+ X,
} from 'lucide-react'
import Link from 'next/link'
+import { useRouter } from 'next/navigation'
import { cn, formatCurrency } from '@/lib/utils'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceExtractionResult } from '@/types'
@@ -46,6 +52,7 @@ interface InboxItem {
document_id: string | null
extracted_data: InvoiceExtractionResult | null
matched_supplier_id: string | null
+ matched_transaction_id: string | null
created_supplier_invoice_id: string | null
error_message: string | null
// Set client-side only while a manual upload is in flight. Replaced by a
@@ -105,11 +112,32 @@ function WorkspaceSkeleton() {
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
+ const router = useRouter()
const fileInputRef = useRef(null)
const [items, setItems] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [selectedId, setSelectedId] = useState(null)
+ // Phone-only master-detail toggle. On screens ('list')
+ // List filter + search (client-side over the already-fetched items list).
+ const [filter, setFilter] = useState<'all' | 'needs_action' | 'done' | 'error'>('all')
+ const [searchTerm, setSearchTerm] = useState('')
+ // Bulk selection. Items linked to a supplier invoice are skipped at delete
+ // time (server returns 409); we still allow them to be selected so the
+ // user can see the "X skipped" toast and learn the rule.
+ const [selectedIds, setSelectedIds] = useState>(new Set())
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false)
+ // Onboarding card visibility. Hides when all three steps are complete or
+ // the user dismissed it. Persisted to localStorage so refresh doesn't
+ // revive a dismissed card.
+ const [onboardingDismissed, setOnboardingDismissed] = useState(false)
+ // Multi-file upload progress. Null when no queue is running. Reflects the
+ // sequential progress through a batch ({ total, done }) so the button can
+ // show "Laddar X av N…".
+ const [uploadQueue, setUploadQueue] = useState<{ total: number; done: number } | null>(null)
const [selected, setSelected] = useState(null)
const [docUrl, setDocUrl] = useState(null)
const [docMime, setDocMime] = useState(null)
@@ -151,6 +179,67 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
fetchInboxAddress()
}, [fetchItems, fetchInboxAddress])
+ // Read the onboarding-dismissed flag from localStorage after mount
+ // (SSR-safe — no window access during initial render).
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ try {
+ setOnboardingDismissed(
+ window.localStorage.getItem('gnubok.inbox.onboarding.dismissed') === '1'
+ )
+ } catch {
+ // private browsing — keep default (show card)
+ }
+ }, [])
+
+ const handleDismissOnboarding = useCallback(() => {
+ try {
+ window.localStorage.setItem('gnubok.inbox.onboarding.dismissed', '1')
+ } catch {
+ // ignore; in-memory state is enough for this session
+ }
+ setOnboardingDismissed(true)
+ }, [])
+
+ // Onboarding card visibility — derived from real progress so a user who
+ // already has a working inbox flow never sees the guide. Once they finish
+ // all three steps, the card auto-hides on next render.
+ const hasInboxAddress = !!inboxAddress
+ const hasAnyItem = items.length > 0
+ const hasResolvedItem = items.some(
+ (it) => !!it.created_supplier_invoice_id || !!it.matched_transaction_id
+ )
+ const showOnboarding =
+ !onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
+
+ // ── List filter + search (client-side over the fetched list) ─
+
+ const filteredItems = useMemo(() => {
+ const term = searchTerm.trim().toLowerCase()
+ return items.filter((item) => {
+ // Status filter
+ const isErr = item.status === 'error'
+ const isDone = !!item.created_supplier_invoice_id || !!item.matched_transaction_id
+ const needsAction = !isErr && !isDone
+ if (filter === 'error' && !isErr) return false
+ if (filter === 'done' && !isDone) return false
+ if (filter === 'needs_action' && !needsAction) return false
+
+ // Search filter — supplier name, email subject/from, placeholder filename
+ if (term === '') return true
+ const haystack = [
+ item.extracted_data?.supplier?.name,
+ item.email_subject,
+ item.email_from,
+ item.fileName,
+ ]
+ .filter((v): v is string => !!v)
+ .join(' ')
+ .toLowerCase()
+ return haystack.includes(term)
+ })
+ }, [items, filter, searchTerm])
+
// ── Selection ──────────────────────────────────────────────
const handleSelect = useCallback(async (id: string) => {
@@ -158,6 +247,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
setSelected(null)
setDocUrl(null)
setDocMime(null)
+ setMobileView('detail')
try {
const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`)
@@ -189,9 +279,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// ── Upload ─────────────────────────────────────────────────
- const uploadFile = useCallback(async (file: File) => {
+ // `autoSelect`: jump the detail pane to the new placeholder/row. Useful
+ // for a one-off drop (user expects to see what just landed). Harmful in
+ // a multi-file queue (selection yanks around as each file processes).
+ const uploadFile = useCallback(async (
+ file: File,
+ options: { autoSelect: boolean } = { autoSelect: true },
+ ) => {
// Optimistic placeholder — gives the user an immediate visual response
- // for the 3–8s while Bedrock extracts. Removed once the real row arrives.
+ // for the 3–8s while extraction runs. Removed once the real row arrives.
const tempId = `temp-${crypto.randomUUID()}`
const placeholder: InboxItem = {
id: tempId,
@@ -204,14 +300,17 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
document_id: null,
extracted_data: null,
matched_supplier_id: null,
+ matched_transaction_id: null,
created_supplier_invoice_id: null,
error_message: null,
isPlaceholder: true,
fileName: file.name,
}
setItems((prev) => [placeholder, ...prev])
- setSelectedId(tempId)
- setSelected(placeholder)
+ if (options.autoSelect) {
+ setSelectedId(tempId)
+ setSelected(placeholder)
+ }
setIsUploading(true)
try {
const fd = new FormData()
@@ -225,13 +324,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
toast({ title: 'Dokument uppladdat', description: file.name })
setItems((prev) => prev.filter((it) => it.id !== tempId))
await fetchItems()
- if (json.data?.inbox_item_id) {
+ if (options.autoSelect && json.data?.inbox_item_id) {
await handleSelect(json.data.inbox_item_id)
}
} catch (err) {
setItems((prev) => prev.filter((it) => it.id !== tempId))
- setSelectedId((prev) => (prev === tempId ? null : prev))
- setSelected((prev) => (prev?.id === tempId ? null : prev))
+ if (options.autoSelect) {
+ setSelectedId((prev) => (prev === tempId ? null : prev))
+ setSelected((prev) => (prev?.id === tempId ? null : prev))
+ }
toast({
title: 'Uppladdning misslyckades',
description: err instanceof Error ? err.message : 'Försök igen.',
@@ -242,18 +343,40 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}
}, [fetchItems, handleSelect, toast])
- const handleFileInputChange = useCallback(async (e: React.ChangeEvent) => {
- const file = e.target.files?.[0]
- if (file) await uploadFile(file)
- if (fileInputRef.current) fileInputRef.current.value = ''
+ // Sequential queue — running multiple extractions concurrently would
+ // hammer pdfjs on slow boxes. Per-file placeholder rows + the queue
+ // counter on the upload button surface progress.
+ const uploadFiles = useCallback(async (files: File[]) => {
+ if (files.length === 0) return
+ if (files.length === 1) {
+ // Single-file drop: keep the historic behavior of jumping the detail
+ // pane to the new item. Skip the queue counter — it would just flash.
+ await uploadFile(files[0], { autoSelect: true })
+ return
+ }
+ setUploadQueue({ total: files.length, done: 0 })
+ try {
+ for (const file of files) {
+ await uploadFile(file, { autoSelect: false })
+ setUploadQueue((q) => (q ? { ...q, done: q.done + 1 } : null))
+ }
+ } finally {
+ setUploadQueue(null)
+ }
}, [uploadFile])
+ const handleFileInputChange = useCallback(async (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files ?? [])
+ if (files.length > 0) await uploadFiles(files)
+ if (fileInputRef.current) fileInputRef.current.value = ''
+ }, [uploadFiles])
+
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
- const file = e.dataTransfer.files?.[0]
- if (file) await uploadFile(file)
- }, [uploadFile])
+ const files = Array.from(e.dataTransfer.files ?? [])
+ if (files.length > 0) await uploadFiles(files)
+ }, [uploadFiles])
// ── Delete ─────────────────────────────────────────────────
@@ -283,6 +406,59 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}
}, [fetchItems, selectedId, toast])
+ const toggleSelected = useCallback((id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }, [])
+
+ const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
+
+ const handleBulkDelete = useCallback(async () => {
+ if (selectedIds.size === 0) return
+ if (!confirm(`Ta bort ${selectedIds.size} poster ur inkorgen?`)) return
+
+ // Skip items that the server would 409 on, surface the count to the user.
+ const targets = items.filter((it) => selectedIds.has(it.id))
+ const deletable = targets.filter((it) => !it.created_supplier_invoice_id)
+ const skipped = targets.length - deletable.length
+
+ setIsBulkDeleting(true)
+ try {
+ const results = await Promise.allSettled(
+ deletable.map((it) =>
+ fetch(`/api/extensions/ext/invoice-inbox/items/${it.id}`, { method: 'DELETE' })
+ .then(async (res) => {
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'fail')
+ })
+ )
+ )
+ const failed = results.filter((r) => r.status === 'rejected').length
+ const succeeded = deletable.length - failed
+ const parts: string[] = []
+ if (succeeded > 0) parts.push(`${succeeded} borttagna`)
+ if (skipped > 0) parts.push(`${skipped} kopplade till leverantörsfaktura — hoppade över`)
+ if (failed > 0) parts.push(`${failed} misslyckades`)
+ toast({
+ title: 'Bulkborttagning klar',
+ description: parts.join(' · '),
+ variant: failed > 0 ? 'destructive' : 'default',
+ })
+ clearSelection()
+ // If the currently-selected item was deleted, clear the rail.
+ if (selectedId && deletable.some((it) => it.id === selectedId)) {
+ setSelectedId(null)
+ setSelected(null)
+ }
+ await fetchItems()
+ } finally {
+ setIsBulkDeleting(false)
+ }
+ }, [selectedIds, items, selectedId, fetchItems, toast, clearSelection])
+
// ── Inbox address ──────────────────────────────────────────
const handleCopyAddress = useCallback(() => {
@@ -382,6 +558,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
)}
- Ladda upp
+ {uploadQueue
+ ? `Laddar ${Math.min(uploadQueue.done + 1, uploadQueue.total)} av ${uploadQueue.total}…`
+ : isUploading
+ ? 'Laddar…'
+ : 'Ladda upp'}
- {/* Three-pane body */}
-
+ {/* Three-pane body. On phone (
{/* List */}
-