Add/docs skv mcp (#494)

* feat: add "book directly" functionality for invoice inbox items

- Extend JournalEntrySourceTypeSchema to include 'inbox_item'.
- Introduce BookInboxItemDirectlySchema for direct journal entry creation.
- Update InvoiceInboxItem type to include matched_transaction_id and created_journal_entry_id.
- Implement BookDirectlyDialog component for user interaction.
- Create API route for booking directly from inbox items with appropriate validations.
- Add SQL migration to support new journal entry references in the invoice inbox items table.
- Implement tests for the new booking functionality and ensure proper error handling.

* fix(invoice-inbox): update status handling for resolved inbox items

* feat: enforce unique journal entry constraint for invoice inbox items
This commit is contained in:
Mattsson
2026-05-15 00:49:59 +02:00
committed by GitHub
parent 523a8650cc
commit b0890c7c79
9 changed files with 1271 additions and 10 deletions
@@ -0,0 +1,690 @@
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import type { BASAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types'
interface InboxItem {
id: string
document_id: string | null
matched_transaction_id: string | null
extracted_data: InvoiceExtractionResult | null
}
interface PickerTransaction {
id: string
date: string
description: string
amount: number
currency: string | null
}
interface FormLine {
account_number: string
debit_amount: string
credit_amount: string
}
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '' }
interface Props {
open: boolean
onOpenChange: (v: boolean) => void
item: InboxItem
onSuccess: () => void | Promise<void>
}
// Compute the prefill lines. Booking is always in SEK (BFL/BFNAR), so when
// a transaction is selected and the document is in a foreign currency, the
// transaction's SEK amount is the canonical figure. The cost-account row
// stays blank — the user must pick a cost account themselves.
function buildPrefillLines(
item: InboxItem,
selectedTransactionAmount: number | null = null
): FormLine[] {
const docTotal = item.extracted_data?.totals?.total ?? null
const docVat = item.extracted_data?.totals?.vatAmount ?? null
const docCurrency = item.extracted_data?.invoice?.currency ?? 'SEK'
// Prefer the transaction amount when available — it's already in SEK and
// matches the bank movement we'll be marking as booked.
const total = selectedTransactionAmount != null
? Math.abs(selectedTransactionAmount)
: docTotal
if (total == null || total <= 0) {
return [{ ...BLANK_LINE }, { ...BLANK_LINE }]
}
const totalRounded = Math.round(total * 100) / 100
// VAT prefill rules:
// - Foreign-currency document → skip VAT (reverse charge is the common
// case; user can add it manually if needed).
// - SEK-denominated document with extracted VAT → split it out on 2641.
// - SEK without extracted VAT → leave VAT row out, single net row.
const useDocVat =
docCurrency === 'SEK' &&
selectedTransactionAmount == null &&
docVat != null &&
docVat > 0
const vatRounded = useDocVat ? Math.round((docVat ?? 0) * 100) / 100 : 0
const net = Math.round((totalRounded - vatRounded) * 100) / 100
const lines: FormLine[] = [
{
account_number: '',
debit_amount: String(net),
credit_amount: '',
},
]
if (vatRounded > 0) {
lines.push({
account_number: '2641',
debit_amount: String(vatRounded),
credit_amount: '',
})
}
lines.push({
account_number: '1930',
debit_amount: '',
credit_amount: String(totalRounded),
})
return lines
}
function rankByAmount(
rows: PickerTransaction[],
target: number | null
): PickerTransaction[] {
if (target == null) return rows
const abs = Math.abs(target)
return [...rows].sort((a, b) => {
const da = Math.abs(Math.abs(a.amount) - abs)
const db = Math.abs(Math.abs(b.amount) - abs)
return da - db
})
}
export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess }: Props) {
const { toast } = useToast()
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [entryDate, setEntryDate] = useState<string>(
item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10)
)
const [periodId, setPeriodId] = useState<string>('')
const [description, setDescription] = useState<string>(() => {
const supplier = item.extracted_data?.supplier?.name?.trim() || ''
const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || ''
return [supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg'
})
const [notes, setNotes] = useState<string>('')
const [lines, setLines] = useState<FormLine[]>(() => buildPrefillLines(item))
// Transaction link state
const [linkToTransaction, setLinkToTransaction] = useState<boolean>(!!item.matched_transaction_id)
const [selectedTransactionId, setSelectedTransactionId] = useState<string | null>(
item.matched_transaction_id
)
const [transactions, setTransactions] = useState<PickerTransaction[]>([])
const [isLoadingTransactions, setIsLoadingTransactions] = useState(false)
const [txSearch, setTxSearch] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Reset state when a different item opens the dialog
useEffect(() => {
if (!open) return
setEntryDate(item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10))
setLines(buildPrefillLines(item))
setLinkToTransaction(!!item.matched_transaction_id)
setSelectedTransactionId(item.matched_transaction_id)
const supplier = item.extracted_data?.supplier?.name?.trim() || ''
const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || ''
setDescription([supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg')
setNotes('')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, item.id])
// When the user picks a transaction (or the toggle changes), re-derive
// the prefilled amounts so foreign-currency invoices follow the SEK
// figure on the actual bank movement.
const selectedTransactionAmount = useMemo(() => {
if (!linkToTransaction || !selectedTransactionId) return null
const tx = transactions.find((t) => t.id === selectedTransactionId)
return tx?.amount ?? null
}, [linkToTransaction, selectedTransactionId, transactions])
useEffect(() => {
if (!open) return
// Update amounts when the transaction selection changes, but preserve
// user-entered account numbers. This handles "user typed cost account,
// then picked an SEK-denominated transaction" — we want the SEK figure
// to flow into the line amounts without forgetting their account pick.
setLines((current) => {
const next = buildPrefillLines(item, selectedTransactionAmount)
return next.map((nl, i) => {
const existing = current[i]
if (!existing) return nl
return {
...nl,
account_number: existing.account_number || nl.account_number,
}
})
})
}, [open, item, selectedTransactionAmount])
// Fetch fiscal periods and accounts on first open
useEffect(() => {
if (!open) return
let cancelled = false
;(async () => {
try {
const [periodsRes, accountsRes] = await Promise.all([
fetch('/api/bookkeeping/fiscal-periods'),
fetch('/api/bookkeeping/accounts'),
])
const periodsJson = await periodsRes.json()
const accountsJson = await accountsRes.json()
if (cancelled) return
setPeriods(periodsJson.data || [])
setAccounts(accountsJson.data || [])
} catch (err) {
console.error('[book-direct] fetch reference data failed:', err)
}
})()
return () => { cancelled = true }
}, [open])
// Auto-select fiscal period matching the entry date
useEffect(() => {
if (periods.length === 0) return
const match = periods.find(
(p) => entryDate >= p.period_start && entryDate <= p.period_end
)
if (match) {
setPeriodId(match.id)
} else if (!periodId && periods.length > 0) {
setPeriodId(periods[0].id)
}
}, [entryDate, periods, periodId])
// Fetch unmatched transactions when the link toggle turns on
useEffect(() => {
if (!open || !linkToTransaction) return
let cancelled = false
setIsLoadingTransactions(true)
const targetAmount = item.extracted_data?.totals?.total ?? null
;(async () => {
try {
const res = await fetch('/api/transactions?unmatched=true')
const json = await res.json()
if (cancelled) return
const rows: PickerTransaction[] = (Array.isArray(json.data) ? json.data : [])
.map((t: PickerTransaction) => ({
id: t.id,
date: t.date,
description: t.description,
amount: t.amount,
currency: t.currency || 'SEK',
}))
setTransactions(rankByAmount(rows, targetAmount))
} catch (err) {
console.error('[book-direct] fetch transactions failed:', err)
} finally {
if (!cancelled) setIsLoadingTransactions(false)
}
})()
return () => { cancelled = true }
}, [open, linkToTransaction, item.extracted_data?.totals?.total])
const filteredTransactions = useMemo(() => {
const term = txSearch.trim().toLowerCase()
if (!term) return transactions
return transactions.filter((t) => (t.description || '').toLowerCase().includes(term))
}, [transactions, txSearch])
const totals = useMemo(() => {
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const roundedDebit = Math.round(debit * 100) / 100
const roundedCredit = Math.round(credit * 100) / 100
return {
debit: roundedDebit,
credit: roundedCredit,
balanced: roundedDebit === roundedCredit && roundedDebit > 0,
diff: Math.round((roundedDebit - roundedCredit) * 100) / 100,
}
}, [lines])
const updateLine = useCallback((idx: number, patch: Partial<FormLine>) => {
setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)))
}, [])
const addLine = useCallback(() => {
setLines((prev) => [...prev, { ...BLANK_LINE }])
}, [])
const removeLine = useCallback((idx: number) => {
setLines((prev) => prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx))
}, [])
const disabledReason = useMemo(() => {
if (isSubmitting) return null
if (!entryDate) return 'Välj datum'
if (!periodId) return 'Välj räkenskapsperiod'
if (description.trim().length === 0) return 'Fyll i beskrivning'
if (lines.some((l) => l.account_number.trim().length === 0)) return 'Alla rader behöver ett konto'
if (!totals.balanced) return 'Debet och kredit måste vara lika'
if (linkToTransaction && !selectedTransactionId) return 'Välj en banktransaktion att koppla till'
return null
}, [isSubmitting, entryDate, periodId, description, lines, totals.balanced, linkToTransaction, selectedTransactionId])
const canSubmit = !isSubmitting && disabledReason === null
const handleSubmit = useCallback(async () => {
if (!canSubmit) return
setIsSubmitting(true)
try {
const payload = {
fiscal_period_id: periodId,
entry_date: entryDate,
description: description.trim(),
notes: notes.trim() || undefined,
lines: lines.map((l) => ({
account_number: l.account_number.trim(),
debit_amount: parseFloat(l.debit_amount) || 0,
credit_amount: parseFloat(l.credit_amount) || 0,
})),
transaction_id: linkToTransaction ? selectedTransactionId ?? undefined : undefined,
}
const res = await fetch(
`/api/extensions/ext/invoice-inbox/items/${item.id}/book-direct`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
)
const json = await res.json().catch(() => ({}))
if (!res.ok) {
toast({
title: 'Kunde inte bokföra',
description: json.error || 'Försök igen.',
variant: 'destructive',
})
return
}
const voucher = json?.data?.journal_entry
toast({
title: 'Bokfört',
description: voucher
? `Verifikation ${voucher.voucher_series}${voucher.voucher_number} skapad.`
: 'Verifikation skapad.',
})
await onSuccess()
onOpenChange(false)
} finally {
setIsSubmitting(false)
}
}, [
canSubmit, periodId, entryDate, description, notes, lines,
linkToTransaction, selectedTransactionId, item.id, toast, onSuccess, onOpenChange,
])
const targetAmount = item.extracted_data?.totals?.total ?? null
const targetCurrency = item.extracted_data?.invoice?.currency ?? 'SEK'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför direkt</DialogTitle>
<DialogDescription>
Skapa en verifikation från underlaget. Dokumentet bifogas verifikationen som underlag.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 pt-2">
{/* Metadata row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label htmlFor="bd-date">Datum</Label>
<Input
id="bd-date"
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
disabled={isSubmitting}
className="tabular-nums"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="bd-period">Räkenskapsperiod</Label>
<Select
value={periodId}
onValueChange={setPeriodId}
disabled={isSubmitting || periods.length === 0}
>
<SelectTrigger id="bd-period">
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
{periods.map((p) => {
const lockState = p.locked_at
? 'låst'
: p.is_closed
? 'stängd'
: null
return (
<SelectItem key={p.id} value={p.id}>
{p.period_start} – {p.period_end}
{lockState && ` (${lockState})`}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="bd-description">Beskrivning</Label>
<Input
id="bd-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
placeholder="Leverantör · fakturanummer"
/>
</div>
{/* Transaction link toggle + picker */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label htmlFor="bd-link-tx" className="text-sm">
Koppla till banktransaktion
</Label>
<p className="text-xs text-muted-foreground">
Slå på om dokumentet motsvarar en redan-bokad bankhändelse. Annars
bokförs det som en fristående verifikation.
</p>
</div>
<Switch
id="bd-link-tx"
checked={linkToTransaction}
onCheckedChange={setLinkToTransaction}
disabled={isSubmitting}
/>
</div>
{linkToTransaction && (
<div className="space-y-2 pt-2 border-t">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök på beskrivning…"
value={txSearch}
onChange={(e) => setTxSearch(e.target.value)}
className="pl-10"
disabled={isSubmitting}
/>
</div>
<div className="max-h-56 overflow-y-auto rounded-md border">
{isLoadingTransactions ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Laddar…
</div>
) : filteredTransactions.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Inga okategoriserade transaktioner.
</p>
) : (
<ul className="divide-y">
{filteredTransactions.slice(0, 30).map((tx) => {
const isSelected = selectedTransactionId === tx.id
return (
<li key={tx.id}>
<button
type="button"
className={cn(
'w-full flex items-center justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors',
isSelected
? 'bg-primary/10 border-l-2 border-primary'
: 'border-l-2 border-transparent hover:bg-accent/40'
)}
onClick={() => setSelectedTransactionId(tx.id)}
disabled={isSubmitting}
>
<span className="shrink-0 w-4 flex items-center justify-center">
{isSelected ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : null}
</span>
<div className="min-w-0 flex-1">
<p className="truncate">{tx.description}</p>
<p className="text-xs text-muted-foreground tabular-nums">{tx.date}</p>
</div>
<span
className={cn(
'tabular-nums text-sm shrink-0',
tx.amount < 0 ? 'text-destructive' : 'text-foreground'
)}
>
{formatCurrency(tx.amount, tx.currency || 'SEK')}
</span>
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
)}
</div>
{/* Journal entry lines */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm">Konteringsrader</Label>
<div className="text-xs text-muted-foreground text-right">
{targetAmount != null && (
<span>
Underlag:{' '}
<span className="tabular-nums font-medium text-foreground">
{formatCurrency(targetAmount, targetCurrency)}
</span>
</span>
)}
{selectedTransactionAmount != null && (
<span>
{targetAmount != null && ' · '}
Transaktion:{' '}
<span className="tabular-nums font-medium text-foreground">
{formatCurrency(Math.abs(selectedTransactionAmount), 'SEK')}
</span>
</span>
)}
</div>
</div>
{targetCurrency !== 'SEK' && selectedTransactionAmount != null && (
<p className="text-[11px] text-muted-foreground">
Underlaget är i {targetCurrency}. Bokföringen sker i SEK enligt
transaktionens belopp. Momsraden har lämnats bort — vid behov
lägg till en rad för omvänd skattskyldighet manuellt.
</p>
)}
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40">
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium px-3 py-2 w-[40%]">Konto</th>
<th className="text-right font-medium px-3 py-2">Debet</th>
<th className="text-right font-medium px-3 py-2">Kredit</th>
<th className="w-10" />
</tr>
</thead>
<tbody className="divide-y">
{lines.map((line, idx) => (
<tr key={idx}>
<td className="px-3 py-2">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(v) => updateLine(idx, { account_number: v })}
/>
</td>
<td className="px-3 py-2">
<Input
type="number"
step="0.01"
inputMode="decimal"
value={line.debit_amount}
onChange={(e) => updateLine(idx, { debit_amount: e.target.value, credit_amount: e.target.value ? '' : line.credit_amount })}
disabled={isSubmitting}
className="text-right tabular-nums"
placeholder="0,00"
/>
</td>
<td className="px-3 py-2">
<Input
type="number"
step="0.01"
inputMode="decimal"
value={line.credit_amount}
onChange={(e) => updateLine(idx, { credit_amount: e.target.value, debit_amount: e.target.value ? '' : line.debit_amount })}
disabled={isSubmitting}
className="text-right tabular-nums"
placeholder="0,00"
/>
</td>
<td className="px-2 py-2 text-right">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeLine(idx)}
disabled={isSubmitting || lines.length <= 2}
aria-label="Ta bort rad"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot className="bg-muted/20 text-xs">
<tr>
<td className="px-3 py-2 text-right font-medium uppercase tracking-wider text-muted-foreground">
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
{totals.debit.toFixed(2)}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
{totals.credit.toFixed(2)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={addLine}
disabled={isSubmitting}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{totals.balanced ? (
<Badge variant="success" className="text-[11px]">
Balanserad
</Badge>
) : (
<span className="text-xs text-muted-foreground flex items-center gap-1.5 tabular-nums">
<AlertTriangle className="h-3.5 w-3.5 text-warning" />
Diff {totals.diff.toFixed(2)}
</span>
)}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="bd-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
Anteckningar (valfritt)
</Label>
<Textarea
id="bd-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={isSubmitting}
rows={2}
placeholder="Intern kommentar om verifikationen"
/>
</div>
<div className="flex items-center justify-between gap-3 pt-2 border-t">
<p
className={cn(
'text-xs tabular-nums',
disabledReason ? 'text-warning-foreground' : 'text-muted-foreground'
)}
aria-live="polite"
>
{disabledReason ?? 'Klar att bokföra.'}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Avbryt
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
title={disabledReason ?? undefined}
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Bokför…
</>
) : (
'Bokför'
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -38,6 +38,9 @@ import {
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
type AccountingMethod = 'accrual' | 'cash'
// ── Types ────────────────────────────────────────────────────
@@ -54,6 +57,7 @@ interface InboxItem {
matched_supplier_id: string | null
matched_transaction_id: string | null
created_supplier_invoice_id: string | null
created_journal_entry_id: string | null
error_message: string | null
// Set client-side only while a manual upload is in flight. Replaced by a
// real server-side row once the AI extraction completes.
@@ -147,6 +151,11 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [isRotating, setIsRotating] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [attachOpen, setAttachOpen] = useState(false)
const [bookDirectOpen, setBookDirectOpen] = useState(false)
// Cash method users see "Bokför direkt" as the primary CTA; accrual users
// see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read
// the company settings so we don't flicker the CTA order on first paint.
const [accountingMethod, setAccountingMethod] = useState<AccountingMethod>('accrual')
// ── Data loading ───────────────────────────────────────────
@@ -177,6 +186,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
useEffect(() => {
fetchItems()
fetchInboxAddress()
// Resolve the company's bookkeeping method — drives CTA hierarchy.
fetch('/api/settings')
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
const method = body?.data?.accounting_method
if (method === 'cash' || method === 'accrual') {
setAccountingMethod(method)
}
})
.catch(() => { /* keep 'accrual' default */ })
}, [fetchItems, fetchInboxAddress])
// Read the onboarding-dismissed flag from localStorage after mount
@@ -207,7 +226,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const hasInboxAddress = !!inboxAddress
const hasAnyItem = items.length > 0
const hasResolvedItem = items.some(
(it) => !!it.created_supplier_invoice_id || !!it.matched_transaction_id
(it) =>
!!it.created_supplier_invoice_id ||
!!it.matched_transaction_id ||
!!it.created_journal_entry_id
)
const showOnboarding =
!onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
@@ -219,7 +241,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
return items.filter((item) => {
// Status filter
const isErr = item.status === 'error'
const isDone = !!item.created_supplier_invoice_id || !!item.matched_transaction_id
const isDone =
!!item.created_supplier_invoice_id ||
!!item.matched_transaction_id ||
!!item.created_journal_entry_id
const needsAction = !isErr && !isDone
if (filter === 'error' && !isErr) return false
if (filter === 'done' && !isDone) return false
@@ -302,6 +327,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
matched_supplier_id: null,
matched_transaction_id: null,
created_supplier_invoice_id: null,
created_journal_entry_id: null,
error_message: null,
isPlaceholder: true,
fileName: file.name,
@@ -423,7 +449,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// 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 deletable = targets.filter(
(it) => !it.created_supplier_invoice_id && !it.created_journal_entry_id
)
const skipped = targets.length - deletable.length
setIsBulkDeleting(true)
@@ -770,8 +798,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
{selected ? (
<FieldsRail
item={selected}
accountingMethod={accountingMethod}
onDelete={() => handleDelete(selected.id)}
onAttach={() => setAttachOpen(true)}
onBookDirect={() => setBookDirectOpen(true)}
isDeleting={isDeleting}
onRetryRequested={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
@@ -825,6 +855,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}}
/>
)}
{selected && (
<BookDirectlyDialog
open={bookDirectOpen}
onOpenChange={setBookDirectOpen}
item={selected}
onSuccess={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
}}
/>
)}
</div>
)
}
@@ -1551,15 +1591,19 @@ function EmptyPreview({
function FieldsRail({
item,
accountingMethod,
onDelete,
onAttach,
onBookDirect,
isDeleting,
onFieldsUpdated,
onRetryRequested,
}: {
item: InboxItem
accountingMethod: AccountingMethod
onDelete: () => void
onAttach: () => void
onBookDirect: () => void
isDeleting: boolean
onFieldsUpdated: (data: InvoiceExtractionResult) => void
onRetryRequested: () => Promise<void>
@@ -1567,8 +1611,10 @@ function FieldsRail({
const { toast } = useToast()
const data = item.extracted_data
const isProcessed = !!item.created_supplier_invoice_id
const isLinkedToTransaction = !isProcessed && !!item.matched_transaction_id
const isResolved = isProcessed || isLinkedToTransaction
const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
const isLinkedToTransaction =
!isProcessed && !isBookedDirectly && !!item.matched_transaction_id
const isResolved = isProcessed || isBookedDirectly || isLinkedToTransaction
const [isRetrying, setIsRetrying] = useState(false)
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
@@ -1780,6 +1826,13 @@ function FieldsRail({
Öppna leverantörsfaktura
</Button>
</Link>
) : isBookedDirectly && item.created_journal_entry_id ? (
<Link href={`/bookkeeping/${item.created_journal_entry_id}`} className="block">
<Button variant="default" size="sm" className="w-full">
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
Öppna verifikation
</Button>
</Link>
) : isLinkedToTransaction && item.matched_transaction_id ? (
<Link href={`/transactions?highlight=${item.matched_transaction_id}`} className="block">
<Button variant="default" size="sm" className="w-full">
@@ -1787,6 +1840,33 @@ function FieldsRail({
Bokför transaktionen
</Button>
</Link>
) : accountingMethod === 'cash' ? (
<>
<Button
variant="default"
size="sm"
className="w-full"
onClick={onBookDirect}
>
Bokför direkt
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={onAttach}
disabled={!item.document_id}
title={!item.document_id ? 'Ingen bilaga att koppla' : undefined}
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
Koppla till transaktion
</Button>
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
<Button variant="ghost" size="sm" className="w-full">
Skapa leverantörsfaktura
</Button>
</Link>
</>
) : (
<>
<Button
@@ -1805,6 +1885,14 @@ function FieldsRail({
Skapa leverantörsfaktura
</Button>
</Link>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={onBookDirect}
>
Bokför direkt
</Button>
</>
)}
<Button
@@ -1816,9 +1904,11 @@ function FieldsRail({
title={
isProcessed
? 'Kopplad till leverantörsfaktura — kan inte tas bort'
: isLinkedToTransaction
? 'Kopplad till transaktion — koppla loss innan borttagning'
: undefined
: isBookedDirectly
? 'Bokförd — kan inte tas bort'
: isLinkedToTransaction
? 'Kopplad till transaktion — koppla loss innan borttagning'
: undefined
}
>
{isDeleting ? (
@@ -1834,6 +1924,12 @@ function FieldsRail({
Bearbetad
</Badge>
)}
{isBookedDirectly && (
<Badge variant="secondary" className="w-full justify-center text-[10px]">
<Check className="h-2.5 w-2.5 mr-1" />
Bokförd
</Badge>
)}
{isLinkedToTransaction && (
<Badge variant="secondary" className="w-full justify-center text-[10px]">
<Link2 className="h-2.5 w-2.5 mr-1" />