feat(transactions): underlag status badges + attach dialog; auto-expire stale pending ops (#712)

* feat(transactions): per-row underlag status + attach-document dialog

- New "Matcha mot underlag" dialog on /transactions (inbox pick or fresh
  upload), the tx→doc mirror of the Documents view's matcher
- Per-row Underlag/Underlag saknas badges on booked history rows, driven
  by computeJeUnderlagStatus — same posted-only, exemption-aware scope as
  the worklist count so badge and count never disagree
- attach-document route + commit dispatcher now propagate the doc onto
  the verifikation when the tx is already booked (BFL 5 kap 6 §), with a
  409 guard for docs consumed by a different verifikation, idempotent
  re-attach (no same-value rewrite under period lock), and an honest 409
  when the period-lock trigger blocks the propagation
- Booking-dialog doc links also pin the doc to the transaction row
  (first linked doc wins) via the link route's new transaction_id param

messages/{sv,en}.json also carries the strings for the pending-ops
expiry UI that lands in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pending-operations): auto-expire stale staged operations after 30 days

- New daily cron (02:30 UTC, vercel.json + both docker crontabs) flips
  >30-day-old pending ops to rejected with the dispatcher's
  { auto_rejected: true, reason: 'expired' } result_data shape — rows are
  never deleted, the table is the audit trail
- /pending renders an "Utgick automatiskt" badge + detail line for these,
  orders terminal tabs by resolved_at so a fresh expiry sweep isn't
  buried, and adds a first-time-reviewer explainer
- Origin labels spell out where a proposal came from (AI chat, MCP key,
  API, cron) instead of the raw actor_label
- agent_chat actor type added to PendingOperationActorType/AuditLogEntry
  (DB CHECK already widened in 20260519090000) and to the agent filter
- ApprovalCard notes that ignoring a proposal is safe

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): surface the client telemetry marker in connect instructions

Tag the connector URLs shown in ApiKeysPanel, the connect-claude doc and
the gnubok-mcp README with ?client=<surface> (claude-connector /
claude-code) and GNUBOK_CLIENT=claude-desktop for the npm bridge.
Telemetry-only — the server already reads the param/header; this just
lets us measure which Claude surface connected.

The claude mcp add copy blocks quote the URL: an unquoted ? in the query
string trips zsh globbing ("no matches found").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: fix stale-closure badge flip + zod-validate link route body (PR #712)

- handleDocumentAttached read journal_entry_id off the render-time
  transactions snapshot; if the list changed while the attach dialog was
  open the optimistic badge flip was silently skipped. Read it off the
  dialog's own subject (attachDocTx) instead.
- POST /api/documents/[id]/link now validates the body against the new
  LinkDocumentSchema (uuid-strict, all four fields) instead of a bare
  presence check on journal_entry_id — same canonical VALIDATION_ERROR
  envelope. Test fixtures switched to real UUIDs accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-11 11:13:51 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e978136210
commit 0521c385d2
29 changed files with 1457 additions and 104 deletions
+30 -23
View File
@@ -396,29 +396,36 @@ export default function ApprovalCard({
</div>
</div>
) : (
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCommit}
disabled={isBusy || !canCommit}
className="flex-1"
>
{state === 'committing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Godkänn'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRejectForm(true)}
disabled={isBusy}
className="flex-1"
>
Avslå
</Button>
</div>
<>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCommit}
disabled={isBusy || !canCommit}
className="flex-1"
>
{state === 'committing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Godkänn'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRejectForm(true)}
disabled={isBusy}
className="flex-1"
>
Avslå
</Button>
</div>
{/* Keep in sync with EXPIRY_DAYS in
app/api/pending-operations/expire/cron/route.ts. */}
<p className="text-[11px] text-muted-foreground">
Om du inte gör något utgår förslaget automatiskt efter 30 dagar — inget bokförs.
</p>
</>
)}
</div>
)
+10 -5
View File
@@ -360,9 +360,12 @@ export function ApiKeysPanel() {
})
}
const mcpUrl = typeof window !== 'undefined'
const mcpBase = typeof window !== 'undefined'
? `${window.location.origin}/api/extensions/ext/mcp-server/mcp`
: '/api/extensions/ext/mcp-server/mcp'
// Telemetry-only distribution-channel marker (server reads the `client` query
// param; never used for auth). Lets us measure which Claude surface connected.
const mcpUrl = (client: string) => `${mcpBase}?client=${client}`
return (
<div className="space-y-6">
@@ -465,7 +468,7 @@ export function ApiKeysPanel() {
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl} copyAriaLabel={t('copy_aria')} />
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div>
<div>
@@ -473,7 +476,8 @@ export function ApiKeysPanel() {
<p className="text-xs text-muted-foreground mb-2">
{t('terminal_runs_browser_login')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} copyAriaLabel={t('copy_aria')} />
{/* URL is quoted — unquoted `?` in the query string trips zsh globbing. */}
<CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
</div>
<div className="border-t pt-4">
@@ -500,7 +504,8 @@ export function ApiKeysPanel() {
"command": "npx",
"args": ["gnubok-mcp"],
"env": {
"GNUBOK_API_KEY": "gnubok_sk_..."
"GNUBOK_API_KEY": "gnubok_sk_...",
"GNUBOK_CLIENT": "claude-desktop"
}
}
}
@@ -513,7 +518,7 @@ export function ApiKeysPanel() {
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url ${mcpUrl} \\
--url "${mcpUrl('claude-code')}" \\
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
@@ -0,0 +1,223 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Inbox, Loader2, X } from 'lucide-react'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker'
import type { TransactionWithInvoice } from './transaction-types'
interface TransactionAttachDocumentDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onAttached: (transactionId: string, documentId: string) => void
}
/**
* Standalone "Matcha mot underlag" dialog for the /transactions view — the
* mirror of the Documents view's TransactionMatchPicker (doc → tx direction).
* Pick an unconsumed inbox document or upload a new file, then pin it to the
* transaction via POST /api/transactions/[id]/attach-document. The pin is
* single-valued (transactions.document_id); for booked rows the route
* propagates the link onto the verifikation immediately.
*/
export default function TransactionAttachDocumentDialog({
open,
onOpenChange,
transaction,
onAttached,
}: TransactionAttachDocumentDialogProps) {
const t = useTranslations('tx_attach_dialog')
const { toast } = useToast()
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [pickedDoc, setPickedDoc] = useState<AvailableInboxDoc | null>(null)
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
const [isAttaching, setIsAttaching] = useState(false)
if (!transaction) return null
const isIncome = transaction.amount > 0
// Single selection — transactions.document_id pins exactly one doc, so an
// inbox pick replaces any upload and vice versa.
const selectedDocumentId =
pickedDoc?.document_id ??
uploadedFiles.find((f) => f.status === 'uploaded' && f.id)?.id ??
null
const reset = () => {
setUploadedFiles([])
setPickedDoc(null)
setInboxPickerOpen(false)
}
const handleAttach = async () => {
if (!selectedDocumentId || isAttaching) return
setIsAttaching(true)
try {
const res = await fetch(`/api/transactions/${transaction.id}/attach-document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_id: selectedDocumentId }),
})
if (!res.ok) {
const json = (await res.json().catch(() => ({}))) as { error?: unknown }
// The route returns Swedish domain messages (immutability, locked
// period) as a plain string — surface them verbatim.
toast({
title: t('error_toast'),
description: typeof json.error === 'string' ? json.error : undefined,
variant: 'destructive',
})
return
}
toast({ title: t('success_toast') })
// Same event AgentChat and the booking dialog dispatch — flips the inbox
// card's indicator optimistically without a refetch.
window.dispatchEvent(
new CustomEvent('Accounted:transaction-document-linked', {
detail: { transaction_id: transaction.id, document_id: selectedDocumentId },
}),
)
onAttached(transaction.id, selectedDocumentId)
reset()
onOpenChange(false)
} catch {
// Network-level failure — fetch rejected before a response existed.
toast({ title: t('error_toast'), variant: 'destructive' })
} finally {
setIsAttaching(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{/* Transaction summary — same block as TransactionBookingDialog */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
{transaction.document_id && (
<p className="text-xs text-muted-foreground">{t('already_attached_hint')}</p>
)}
<div className="space-y-2">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={(files) => {
setUploadedFiles(files)
if (files.length > 0) setPickedDoc(null)
}}
maxFiles={1}
compact
disabled={isAttaching}
/>
{pickedDoc && (
<div className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">
{pickedDoc.supplier_name ?? pickedDoc.file_name}
</span>
{pickedDoc.amount != null && (
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{formatCurrency(pickedDoc.amount, pickedDoc.currency ?? 'SEK')}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
aria-label={t('selected_remove')}
onClick={() => setPickedDoc(null)}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={isAttaching}
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="h-4 w-4 mr-2" />
{t('pick_existing')}
</Button>
</div>
<DialogFooter>
<Button variant="outline" disabled={isAttaching} onClick={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button disabled={!selectedDocumentId || isAttaching} onClick={handleAttach}>
{isAttaching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('attaching')}
</>
) : (
t('confirm')
)}
</Button>
</DialogFooter>
<InboxDocumentPicker
open={inboxPickerOpen}
onClose={() => setInboxPickerOpen(false)}
onSelect={(doc) => {
setPickedDoc(doc)
setUploadedFiles([])
}}
/>
</DialogContent>
</Dialog>
)
}
@@ -1,36 +1,64 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Paperclip, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
interface Props {
documentId: string | null | undefined
/** The booked tx's journal entry — link target when the underlag lives only
* at verifikat level (multi-doc entries, booking-dialog uploads). */
journalEntryId?: string | null
/** Underlag exists on the journal entry even though no doc is pinned to the
* transaction row (computeJeUnderlagStatus === 'has'). */
hasJeDoc?: boolean
/** Booked, requires underlag, has none (computeJeUnderlagStatus === 'missing'). */
missing?: boolean
/** Opens the attach dialog from the negative state. Omit for viewers —
* the badge then renders non-interactive. */
onAttach?: () => void
className?: string
}
const badgeClass = 'h-4 gap-1 px-1.5 py-0 text-[10px] font-normal'
// Enlarges the hit area beyond the 16px badge without shifting layout.
const hitAreaClass = 'shrink-0 p-1 -m-1'
/**
* Compact "this transaction has an attached document" indicator.
* Clicking fetches a signed download URL and opens the document in a new tab —
* lets the user verify the attached receipt without first having to book the
* transaction (which is when the doc gets linked to a journal entry).
* Per-row underlag status for the /transactions lists.
*
* - Pinned doc (transactions.document_id): clickable badge that fetches a
* signed URL and opens the document in a new tab.
* - Verifikat-level doc only: same badge, links to the verifikat page (which
* lists all attachments — handles multi-doc without a per-row fetch).
* - Missing on a booked row: discreet outline badge that doubles as the
* attach affordance when onAttach is provided.
*/
export function TransactionAttachmentIndicator({ documentId, className }: Props) {
export function TransactionAttachmentIndicator({
documentId,
journalEntryId,
hasJeDoc,
missing,
onAttach,
className,
}: Props) {
const t = useTranslations('tx_underlag')
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
if (!documentId) return null
const handleOpen = async (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
if (isLoading) return
if (isLoading || !documentId) return
setIsLoading(true)
try {
const res = await fetch(`/api/documents/${documentId}`)
if (!res.ok) {
toast({ title: 'Kunde inte hämta underlaget', variant: 'destructive' })
toast({ title: t('open_failed'), variant: 'destructive' })
return
}
const { data } = await res.json()
@@ -42,22 +70,68 @@ export function TransactionAttachmentIndicator({ documentId, className }: Props)
}
}
return (
<button
type="button"
onClick={handleOpen}
title="Underlag bifogat — klicka för att öppna"
aria-label="Öppna bifogat underlag"
className={cn(
'inline-flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors shrink-0',
className
)}
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
if (documentId) {
return (
<button
type="button"
onClick={handleOpen}
title={t('attached_title')}
aria-label={t('attached_aria')}
className={cn(hitAreaClass, className)}
>
<Badge variant="secondary" className={badgeClass}>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Paperclip className="h-3 w-3" />
)}
{t('attached_label')}
</Badge>
</button>
)
}
if (hasJeDoc && journalEntryId) {
return (
<Link
href={`/bookkeeping/${journalEntryId}`}
title={t('attached_title')}
aria-label={t('attached_aria')}
onClick={(e) => e.stopPropagation()}
className={cn(hitAreaClass, className)}
>
<Badge variant="secondary" className={badgeClass}>
<Paperclip className="h-3 w-3" />
{t('attached_label')}
</Badge>
</Link>
)
}
if (missing) {
const badge = (
<Badge variant="outline" className={cn(badgeClass, 'text-muted-foreground')}>
<Paperclip className="h-3 w-3" />
)}
</button>
)
{t('missing_label')}
</Badge>
)
if (!onAttach) return <span className={cn('shrink-0', className)}>{badge}</span>
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onAttach()
}}
title={t('missing_title')}
aria-label={t('missing_title')}
className={cn(hitAreaClass, className)}
>
{badge}
</button>
)
}
return null
}
@@ -23,7 +23,11 @@ interface TransactionBookingDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onBooked: (transactionId: string, journalEntryId: string) => void
onBooked: (
transactionId: string,
journalEntryId: string,
attachedDocumentId?: string | null,
) => void
preselectedTemplate?: BookingTemplateLibrary | null
}
@@ -116,16 +120,23 @@ export default function TransactionBookingDialog({
// files, and existing inbox documents picked via InboxDocumentPicker. For
// picked docs, inbox_item_id stamps the inbox item as consumed so it drops
// out of the active inbox — see app/api/documents/[id]/link/route.ts.
// transaction_id additionally pins the doc to the transaction row so the
// /transactions list shows the underlag indicator (first linked doc wins).
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
let linkFailCount = 0
let firstLinkedDocId: string | null = null
for (const file of filesToLink) {
try {
const res = await fetch(`/api/documents/${file.id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
body: JSON.stringify({
journal_entry_id: journalEntryId,
transaction_id: transactionId,
}),
})
if (!res.ok) linkFailCount++
else firstLinkedDocId ??= file.id ?? null
} catch {
linkFailCount++
}
@@ -138,9 +149,11 @@ export default function TransactionBookingDialog({
body: JSON.stringify({
journal_entry_id: journalEntryId,
inbox_item_id: doc.inbox_item_id,
transaction_id: transactionId,
}),
})
if (!res.ok) linkFailCount++
else firstLinkedDocId ??= doc.document_id
} catch {
linkFailCount++
}
@@ -153,10 +166,24 @@ export default function TransactionBookingDialog({
})
}
// The server pins only when the tx has no document_id yet (first linked
// doc wins) — mirror that here so the optimistic state never claims a
// pin the server refused to swap.
const pinnedDocId = transaction.document_id ? null : firstLinkedDocId
if (pinnedDocId) {
// Same event AgentChat dispatches after uploads — flips the inbox card's
// paperclip optimistically without a refetch.
window.dispatchEvent(
new CustomEvent('Accounted:transaction-document-linked', {
detail: { transaction_id: transactionId, document_id: pinnedDocId },
}),
)
}
setUploadedFiles([])
setPickedInboxDocs([])
setShowUploadZone(false)
onBooked(transactionId, journalEntryId)
onBooked(transactionId, journalEntryId, pinnedDocId)
}
const attachedCount =
@@ -38,11 +38,13 @@ import {
FileText,
Loader2,
MoreHorizontal,
Paperclip,
Trash2,
} from 'lucide-react'
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status'
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
@@ -59,8 +61,13 @@ interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
skvRows?: SkattekontoTransactionWithSuggestion[]
searchTerm?: string
/** Underlag status per journal_entry_id (computeJeUnderlagStatus) — drives
* the per-row "Underlag"/"Underlag saknas" badges on booked rows. */
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
/** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
onSkvBokfor?: (row: StoredSkattekontoTransaction) => void
onSkvMatch?: (row: StoredSkattekontoTransaction) => void
@@ -73,8 +80,10 @@ export default function TransactionHistoryList({
transactions,
skvRows = [],
searchTerm = '',
jeUnderlagStatus,
onOpenMatchDialog,
onOpenCategoryDialog,
onOpenAttachDocument,
onDelete,
onSkvBokfor,
onSkvMatch,
@@ -177,8 +186,10 @@ export default function TransactionHistoryList({
<BankHistoryRow
key={`bank-${item.data.id}`}
transaction={item.data}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={onOpenMatchDialog}
onOpenCategoryDialog={onOpenCategoryDialog}
onOpenAttachDocument={onOpenAttachDocument}
onDelete={onDelete}
/>
) : (
@@ -213,13 +224,17 @@ export default function TransactionHistoryList({
function BankHistoryRow({
transaction,
jeUnderlagStatus,
onOpenMatchDialog,
onOpenCategoryDialog,
onOpenAttachDocument,
onDelete,
}: {
transaction: TransactionWithInvoice
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
}) {
const t = useTranslations('tx_history')
@@ -237,6 +252,15 @@ function BankHistoryRow({
const hasInvoiceMatch =
!isLinkedToInvoice && !!transaction.potential_invoice && !isBooked
// Underlag status — see computeJeUnderlagStatus. Unknown/not-yet-loaded JE
// renders neither badge (no false "saknas" flash while the enrichment loads).
const jeStatus = transaction.journal_entry_id
? jeUnderlagStatus?.[transaction.journal_entry_id]
: undefined
const hasJeDoc = jeStatus === 'has'
const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing'
const showAttachItem = canWrite && !!onOpenAttachDocument
// Primary status badge — pick the most informative one.
const statusBadge = (() => {
if (isBooked) {
@@ -319,7 +343,7 @@ function BankHistoryRow({
</Link>
</Button>
)}
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite)) && (
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite) || showAttachItem) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -340,6 +364,14 @@ function BankHistoryRow({
})}
</DropdownMenuItem>
)}
{/* Attach underlag — available on both booked rows (the route
propagates the doc onto the verifikation) and unbooked. */}
{showAttachItem && (
<DropdownMenuItem onSelect={() => onOpenAttachDocument!(transaction)}>
<Paperclip className="h-3.5 w-3.5" />
{t('attach_document')}
</DropdownMenuItem>
)}
{isBooked && canWrite && transaction.journal_entry_id && (
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
{({ open, isLoading }) => (
@@ -351,7 +383,7 @@ function BankHistoryRow({
)}
{canDelete && onDelete && (
<>
{hasInvoiceMatch && <DropdownMenuSeparator />}
{(hasInvoiceMatch || showAttachItem) && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={() => onDelete(transaction.id)}
className="text-destructive focus:text-destructive"
@@ -369,7 +401,15 @@ function BankHistoryRow({
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary>{transaction.description}</DataListPrimary>
<TransactionAttachmentIndicator documentId={transaction.document_id} />
<TransactionAttachmentIndicator
documentId={transaction.document_id}
journalEntryId={transaction.journal_entry_id}
hasJeDoc={hasJeDoc}
missing={missingUnderlag}
onAttach={
showAttachItem ? () => onOpenAttachDocument!(transaction) : undefined
}
/>
</div>
<DataListMeta>
<span className="tabular-nums">{formatDate(transaction.date)}</span>
@@ -25,6 +25,7 @@ import {
Link2,
Loader2,
MoreHorizontal,
Paperclip,
Pencil,
Split,
Trash2,
@@ -43,6 +44,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
// upload functional but drop the "AI:n läser dokumentet" promise.
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
interface TransactionInboxCardProps {
@@ -66,6 +68,9 @@ interface TransactionInboxCardProps {
/** Open the existing-verifikat matcher — link the bank tx to an already-booked
* voucher (salary, Fortnox import, manual entry) with no new bokföring. */
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
/** Open the attach-underlag dialog — pin an inbox document or a fresh upload
* to the transaction (the tx→doc mirror of the Documents view's matcher). */
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
/** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */
@@ -84,6 +89,7 @@ export default function TransactionInboxCard({
onOpenMatchInvoicePicker,
onOpenSplitMatch,
onOpenMatchVoucher,
onOpenAttachDocument,
onOpenCategoryDialog,
onDelete,
onEditTitle,
@@ -91,6 +97,9 @@ export default function TransactionInboxCard({
onAnimationComplete,
}: TransactionInboxCardProps) {
const t = useTranslations('tx_inbox_card')
// Attaching underlag is a write — hide the affordance from viewers so they
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
const { canWrite } = useCanWrite()
const isProcessing = processingId === transaction.id
const isDisabled = processingId !== null && processingId !== transaction.id
const isIncome = transaction.amount > 0
@@ -224,10 +233,14 @@ export default function TransactionInboxCard({
// invoice match was auto-detected: the user may want to point the bank line at
// an existing salary/Fortnox/manual voucher instead of confirming a payment.
const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher
// "Matcha mot underlag" — pin an inbox doc / fresh upload to the tx. The
// tx→doc mirror of the Documents view's "Matcha mot transaktion".
const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument
const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch
const showEditItem = isTitleEditable && !!onEditTitle
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu = showMatchVoucherItem || showSplitItem || showEditItem || showDeleteItem
const showOverflowMenu =
showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showDeleteItem
return (
<motion.div
@@ -344,6 +357,17 @@ export default function TransactionInboxCard({
{t('match_voucher_btn')}
</DropdownMenuItem>
)}
{showAttachDocumentItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenAttachDocument!(transaction)
}}
>
<Paperclip className="h-4 w-4" />
{t('attach_document_btn')}
</DropdownMenuItem>
)}
{showSplitItem && (
<DropdownMenuItem
onClick={(e) => {
@@ -368,7 +392,7 @@ export default function TransactionInboxCard({
)}
{showDeleteItem && (
<>
{(showMatchVoucherItem || showSplitItem || showEditItem) && <DropdownMenuSeparator />}
{(showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {