Fix/m sprint fixes (#613)

* fix(dashboard): exclude ignored and already-triaged transactions from stale count

The "Gamla transaktioner" widget counted transactions that had been ignored
or already marked as is_business=true but not yet booked, so users saw a
nag for a row they had already dealt with — and the /transactions inbox
correctly hid it. Align the count with the inbox criterion (is_business
IS NULL, is_ignored = false) so the widget clears when the row leaves
the inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): read entity_type from settings response wrapper

The transactions page read entityRes.entity_type directly, but
/api/settings returns { data: { entity_type, ... } }. The expression
was always undefined, so setEntityType never fired and entityType
stayed at its initial 'enskild_firma'. The template picker's
entity_type filter then dropped every aktiebolag-tagged user template
for AB customers — only entity_type='all' templates made it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stale templates
bank sync
journal entry from transaction

* fixed pr comments

* fixed pr comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-30 01:28:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fc7a46c3f2
commit ea1bf01f1e
76 changed files with 5474 additions and 532 deletions
+3 -3
View File
@@ -196,7 +196,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc
{isOpen && flatList.length > 0 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 w-64 max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
className="absolute z-50 top-full left-0 mt-1 min-w-[20rem] w-[max(100%,28rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
>
{groupedAccounts.map((group) => (
<div key={group.className}>
@@ -221,7 +221,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="truncate">{account.account_name}</span>
<span className="break-words">{account.account_name}</span>
</button>
)
})}
@@ -232,7 +232,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc
{/* Empty state */}
{isOpen && search.trim() && flatList.length === 0 && (
<div className="absolute z-50 top-full left-0 mt-1 w-64 rounded-md border border-input bg-card shadow-md p-3">
<div className="absolute z-50 top-full left-0 mt-1 min-w-[20rem] w-[max(100%,28rem)] rounded-md border border-input bg-card shadow-md p-3">
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
</p>
@@ -50,12 +50,22 @@ interface AttachmentPreviewSheetProps {
type IntegrityState = 'valid' | 'invalid' | 'error'
const integrityCache = new Map<string, IntegrityState>()
function isImageType(type: string | null): boolean {
return type?.startsWith('image/') ?? false
function isImageType(type: string | null, fileName?: string): boolean {
if (type?.startsWith('image/')) return true
// Legacy uploads and browsers that fail to sniff sometimes leave mime_type
// null or set it to application/octet-stream — fall back to filename.
if (type === null || type === 'application/octet-stream') {
return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '')
}
return false
}
function isPdfType(type: string | null): boolean {
return type === 'application/pdf'
function isPdfType(type: string | null, fileName?: string): boolean {
if (type === 'application/pdf') return true
if (type === null || type === 'application/octet-stream') {
return fileName?.toLowerCase().endsWith('.pdf') ?? false
}
return false
}
function formatFileSize(bytes: number): string {
@@ -247,13 +257,15 @@ export default function AttachmentPreviewSheet({
<div className="space-y-6">
{documents.map((doc) => {
const inlineSrc = `/api/documents/${doc.id}/inline`
const previewable = isImageType(doc.mime_type) || isPdfType(doc.mime_type)
const previewable =
isImageType(doc.mime_type, doc.file_name) ||
isPdfType(doc.mime_type, doc.file_name)
const isReplacing = replacingDocId === doc.id
return (
<div key={doc.id} className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
{isImageType(doc.mime_type) ? (
{isImageType(doc.mime_type, doc.file_name) ? (
<ImageIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
@@ -307,7 +319,7 @@ export default function AttachmentPreviewSheet({
</div>
</div>
{isPdfType(doc.mime_type) && integrity[doc.id] === 'invalid' && (
{isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] === 'invalid' && (
<div className="flex h-[70vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-6 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-warning/15">
<AlertTriangle className="h-5 w-5 text-warning-foreground" />
@@ -332,7 +344,7 @@ export default function AttachmentPreviewSheet({
</div>
)}
{isPdfType(doc.mime_type) && integrity[doc.id] !== 'invalid' && (
{isPdfType(doc.mime_type, doc.file_name) && integrity[doc.id] !== 'invalid' && (
// <object> + type="application/pdf" invokes Chrome's PDF
// plugin directly. <iframe> went through Chrome's frame
// pipeline first and intermittently surfaced
@@ -378,7 +390,7 @@ export default function AttachmentPreviewSheet({
</>
)}
{isImageType(doc.mime_type) && (
{isImageType(doc.mime_type, doc.file_name) && (
<div className="overflow-hidden rounded-lg border border-border bg-muted/30">
<img
src={inlineSrc}
@@ -12,8 +12,8 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import CorrectionPreview from '@/components/bookkeeping/CorrectionPreview'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Plus, Trash2 } from 'lucide-react'
@@ -163,63 +163,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
</p>
</div>
{/* Original entry (read-only) */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{/* Original entry metadata — lines live inside CorrectionPreview below */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<span className="font-mono">{formatVoucher(entry)}</span>
<span className="tabular-nums">{formatDate(entry.entry_date)}</span>
<Badge variant="outline" className="text-xs">Original</Badge>
</div>
<p className="text-sm">{entry.description}</p>
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-1.5 w-48">Konto</th>
<th className="py-1.5">Beskrivning</th>
<th className="py-1.5 w-28 text-right">Debet</th>
<th className="py-1.5 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{originalLines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-1.5"><AccountNumber number={line.account_number} showName /></td>
<td className="py-1.5 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-1.5 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-1.5 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-1.5">
{originalLines.map((line) => (
<div key={line.id} className="flex items-center justify-between py-1.5 border-b last:border-0 text-sm">
<div className="min-w-0">
<AccountNumber number={line.account_number} showName />
</div>
<span className="font-mono text-xs shrink-0 ml-2">
{Number(line.debit_amount) > 0
? `D ${Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}`
: `K ${Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}`}
</span>
</div>
))}
</div>
</div>
{/* Divider */}
<div className="border-t my-2" />
{/* Live diff: original | storno | correction | förändring */}
<CorrectionPreview originalLines={originalLines} correctedLines={lines} />
{/* Corrected lines (editable) */}
<div className="space-y-2">
@@ -0,0 +1,114 @@
'use client'
import { AccountNumber } from '@/components/ui/account-number'
import {
buildCorrectionRows,
formatSignedAmount,
type CorrectionLineInput,
} from '@/components/bookkeeping/correction-preview-rows'
import type { JournalEntryLine } from '@/types'
interface Props {
originalLines: JournalEntryLine[]
correctedLines: CorrectionLineInput[]
}
function signClass(n: number): string {
if (n > 0) return 'text-success'
if (n < 0) return 'text-destructive'
return 'text-muted-foreground'
}
export default function CorrectionPreview({ originalLines, correctedLines }: Props) {
const rows = buildCorrectionRows(originalLines, correctedLines)
const hasAnyCorrection = correctedLines.some((l) => {
if (l.account_number.length !== 4) return false
const d = typeof l.debit_amount === 'string' ? parseFloat(l.debit_amount) : l.debit_amount
const c = typeof l.credit_amount === 'string' ? parseFloat(l.credit_amount) : l.credit_amount
return (Number.isFinite(d) && d > 0) || (Number.isFinite(c) && c > 0)
})
if (rows.length === 0) return null
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Effekt per konto</p>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
Debet − Kredit
</p>
</div>
<div className="hidden sm:block rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground bg-muted/30">
<tr>
<th className="px-3 py-2 text-left w-56">Konto</th>
<th className="px-3 py-2 text-right">Original</th>
<th className="px-3 py-2 text-right">Storno</th>
<th className="px-3 py-2 text-right">Rättelse</th>
<th className="px-3 py-2 text-right border-l">Förändring</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.account_number} className="border-t">
<td className="px-3 py-1.5">
<AccountNumber number={row.account_number} showName size="sm" />
</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.original)}`}>
{formatSignedAmount(row.original)}
</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.storno)}`}>
{formatSignedAmount(row.storno)}
</td>
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.correction)}`}>
{hasAnyCorrection ? formatSignedAmount(row.correction) : '–'}
</td>
<td
className={`px-3 py-1.5 text-right tabular-nums border-l font-medium ${signClass(row.delta)}`}
>
{hasAnyCorrection ? formatSignedAmount(row.delta) : '–'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{rows.map((row) => (
<div key={row.account_number} className="rounded-lg border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<AccountNumber number={row.account_number} showName size="sm" />
<span className={`text-sm font-medium tabular-nums ${signClass(row.delta)}`}>
{hasAnyCorrection ? formatSignedAmount(row.delta) : '–'}
</span>
</div>
<dl className="grid grid-cols-3 gap-2 text-xs">
<div>
<dt className="text-muted-foreground">Original</dt>
<dd className={`tabular-nums ${signClass(row.original)}`}>{formatSignedAmount(row.original)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Storno</dt>
<dd className={`tabular-nums ${signClass(row.storno)}`}>{formatSignedAmount(row.storno)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Rättelse</dt>
<dd className={`tabular-nums ${signClass(row.correction)}`}>
{hasAnyCorrection ? formatSignedAmount(row.correction) : '–'}
</dd>
</div>
</dl>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Förändring = storno + rättelse. Det är det netto som tillkommer ovanpå originalet när du
bokför.
</p>
</div>
)
}
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest'
import { makeJournalEntryLine } from '@/tests/helpers'
import {
buildCorrectionRows,
formatSignedAmount,
} from '@/components/bookkeeping/correction-preview-rows'
describe('buildCorrectionRows', () => {
it('returns no rows when both inputs are empty', () => {
expect(buildCorrectionRows([], [])).toEqual([])
})
it('amount change on same accounts — storno cancels original, correction adds the new value', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
]
const corrected = [
{ account_number: '5410', debit_amount: 1200, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1200 },
]
const rows = buildCorrectionRows(original, corrected)
expect(rows).toEqual([
{ account_number: '1930', original: -1000, storno: 1000, correction: -1200, delta: -200 },
{ account_number: '5410', original: 1000, storno: -1000, correction: 1200, delta: 200 },
])
})
it('account swap — old account zeros out, new account picks up the value', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
]
const corrected = [
{ account_number: '5420', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
const rows = buildCorrectionRows(original, corrected)
// 5410 ends at delta=-1000 (drained back), 5420 ends at delta=+1000 (new),
// 1930 nets to zero — correction matches storno exactly.
expect(rows.find((r) => r.account_number === '5410')).toEqual({
account_number: '5410',
original: 1000,
storno: -1000,
correction: 0,
delta: -1000,
})
expect(rows.find((r) => r.account_number === '5420')).toEqual({
account_number: '5420',
original: 0,
storno: 0,
correction: 1000,
delta: 1000,
})
expect(rows.find((r) => r.account_number === '1930')).toEqual({
account_number: '1930',
original: -1000,
storno: 1000,
correction: -1000,
delta: 0,
})
})
it('identical correction has zero delta on every row', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
]
const corrected = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
const rows = buildCorrectionRows(original, corrected)
expect(rows.every((r) => r.delta === 0)).toBe(true)
})
it('accepts string amounts from form inputs', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
]
const corrected = [
{ account_number: '5410', debit_amount: '1500.50', credit_amount: '' },
]
const rows = buildCorrectionRows(original, corrected)
expect(rows[0]).toEqual({
account_number: '5410',
original: 1000,
storno: -1000,
correction: 1500.5,
delta: 500.5,
})
})
it('skips corrected lines with partial account numbers (mid-edit)', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
]
const corrected = [
{ account_number: '54', debit_amount: 1200, credit_amount: 0 },
{ account_number: '193', debit_amount: 0, credit_amount: 1200 },
]
const rows = buildCorrectionRows(original, corrected)
// Only original 5410 shows up; the partial entries are ignored.
expect(rows).toHaveLength(1)
expect(rows[0].account_number).toBe('5410')
expect(rows[0].correction).toBe(0)
})
it('rounds to öre to avoid 0.1+0.2 drift', () => {
const original = [
makeJournalEntryLine({ account_number: '5410', debit_amount: 0.1, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '5410', debit_amount: 0.2, credit_amount: 0 }),
]
const rows = buildCorrectionRows(original, [])
expect(rows[0].original).toBe(0.3)
expect(rows[0].storno).toBe(-0.3)
})
})
describe('formatSignedAmount', () => {
it('formats positive amounts with leading +', () => {
expect(formatSignedAmount(1200)).toBe('+1\u00a0200,00')
})
it('formats negative amounts with unicode minus', () => {
expect(formatSignedAmount(-1000)).toBe('−1\u00a0000,00')
})
it('renders zero as en-dash', () => {
expect(formatSignedAmount(0)).toBe('–')
})
it('always renders two decimals', () => {
expect(formatSignedAmount(5.5)).toBe('+5,50')
})
})
@@ -0,0 +1,87 @@
import type { JournalEntryLine } from '@/types'
export interface CorrectionLineInput {
account_number: string
debit_amount: string | number
credit_amount: string | number
}
export interface AccountRow {
account_number: string
original: number
storno: number
correction: number
delta: number
}
function toNumber(v: string | number | null | undefined): number {
if (v == null) return 0
const n = typeof v === 'string' ? parseFloat(v) : v
return Number.isFinite(n) ? n : 0
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/**
* Build per-account diff rows: original net, storno (= −original), proposed
* correction net, and förändring (= storno + correction = correction − original).
*
* Net per row is debit − credit. Accounts appearing only on one side still get
* a row, so the user sees account swaps clearly (old account drains to zero,
* new account picks up the value).
*
* Corrected lines with account_number.length !== 4 are skipped — those are
* incomplete user input mid-edit, not real proposals.
*/
export function buildCorrectionRows(
original: JournalEntryLine[],
corrected: CorrectionLineInput[]
): AccountRow[] {
const map = new Map<string, AccountRow>()
const ensure = (acc: string): AccountRow => {
let row = map.get(acc)
if (!row) {
row = { account_number: acc, original: 0, storno: 0, correction: 0, delta: 0 }
map.set(acc, row)
}
return row
}
for (const line of original) {
if (!line.account_number) continue
const net = toNumber(line.debit_amount) - toNumber(line.credit_amount)
const row = ensure(line.account_number)
row.original += net
row.storno -= net
}
for (const line of corrected) {
if (!line.account_number || line.account_number.length !== 4) continue
const net = toNumber(line.debit_amount) - toNumber(line.credit_amount)
const row = ensure(line.account_number)
row.correction += net
}
for (const row of map.values()) {
row.original = round2(row.original)
row.storno = round2(row.storno)
row.correction = round2(row.correction)
row.delta = round2(row.storno + row.correction)
}
return Array.from(map.values()).sort((a, b) =>
a.account_number.localeCompare(b.account_number)
)
}
export function formatSignedAmount(n: number): string {
if (n === 0) return '–'
const abs = Math.abs(n).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
return n > 0 ? `+${abs}` : `−${abs}`
}
+5 -1
View File
@@ -32,6 +32,9 @@ interface InvoiceReviewContentProps {
numberPreview?: string | null
/** Mirrors `company_settings.ore_rounding`. Defaults to true to match `getDisplayTotal`. */
oreRounding?: boolean
/** Mirrors `company_settings.vat_registered`. When false and the invoice carries
* no VAT, the moms row is suppressed to match the PDF (pdf-template.tsx:876). */
vatRegistered?: boolean
}
export function InvoiceReviewContent({
@@ -48,6 +51,7 @@ export function InvoiceReviewContent({
notes,
numberPreview,
oreRounding,
vatRegistered,
}: InvoiceReviewContentProps) {
const t = useTranslations('invoice_review')
const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true })
@@ -161,7 +165,7 @@ export function InvoiceReviewContent({
<span>{formatCurrency(vat, currency)}</span>
</div>
))}
{Array.from(vatByRate.values()).every((vat) => vat === 0) && (
{Array.from(vatByRate.values()).every((vat) => vat === 0) && !(vatRegistered === false && vatAmount === 0) && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(0, currency)}</span>
+517 -65
View File
@@ -1,15 +1,29 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal, Search, X } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import type { CashAccount } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -63,6 +77,7 @@ interface UnmatchedTransaction {
amount: number
reference: string | null
currency: string
is_ignored?: boolean
}
interface MatchedTransaction {
@@ -88,6 +103,153 @@ interface DryRunMatch {
confidence: number
}
// ============================================================
// Searchable verifikation picker
// ============================================================
/**
* Inline combobox for choosing a journal entry to match a bank transaction
* against. The native <select> couldn't be searched, and the unmatched-GL list
* routinely runs to hundreds of rows (historical SIE imports), so the old UX
* forced users to scroll a giant unsorted dropdown. This picker filters by
* voucher number, date, amount or description as the user types, and renders
* the selected verifikation as a removable chip.
*/
interface MatchPickerProps {
glLines: UnlinkedGLLine[]
value: string
onChange: (journalEntryId: string) => void
disabled?: boolean
placeholder?: string
}
function MatchVerifikationPicker({
glLines,
value,
onChange,
disabled,
placeholder = 'Sök ver.nr, datum, belopp eller beskrivning…',
}: MatchPickerProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onDocMouseDown(e: MouseEvent) {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDocMouseDown)
return () => document.removeEventListener('mousedown', onDocMouseDown)
}, [open])
const selected = glLines.find((l) => l.journal_entry_id === value) || null
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q.length === 0
? glLines
: glLines.filter((line) => {
const amt = (line.debit_amount > 0 ? line.debit_amount : line.credit_amount).toString()
return (
formatVoucher(line).toLowerCase().includes(q) ||
line.entry_date.toLowerCase().includes(q) ||
amt.includes(q) ||
(line.entry_description || '').toLowerCase().includes(q) ||
(line.line_description || '').toLowerCase().includes(q)
)
})
return base.slice(0, 25)
}, [search, glLines])
if (selected) {
const amount = selected.debit_amount > 0 ? selected.debit_amount : -selected.credit_amount
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
<span className="font-mono tabular-nums shrink-0">{formatCurrency(amount)}</span>
<span className="truncate text-muted-foreground">{selected.entry_description}</span>
<Button
type="button"
size="icon"
variant="ghost"
className="ml-auto h-6 w-6 shrink-0"
onClick={() => onChange('')}
disabled={disabled}
aria-label="Avmarkera verifikation"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
return (
<div ref={containerRef} className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
className="pl-9"
/>
</div>
{open && (
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover shadow-[var(--shadow-md)]">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
Inga verifikationer matchar &quot;{search}&quot;
</div>
) : (
<div className="max-h-72 overflow-y-auto">
{filtered.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<button
key={line.line_id}
type="button"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-secondary/60 focus:bg-secondary/60 focus:outline-none"
onMouseDown={(e) => {
// mousedown beats blur — without this the popover closes
// before the click registers when the user has tabbed
// through and uses keyboard.
e.preventDefault()
}}
onClick={() => {
onChange(line.journal_entry_id)
setSearch('')
setOpen(false)
}}
>
<span className="font-mono text-xs shrink-0 w-12">{formatVoucher(line)}</span>
<span className="text-muted-foreground shrink-0 tabular-nums w-24">{formatDate(line.entry_date)}</span>
<span className="font-mono tabular-nums shrink-0 w-24 text-right">{formatCurrency(amount)}</span>
<span className="truncate text-muted-foreground flex-1">
{line.line_description || line.entry_description}
</span>
</button>
)
})}
{glLines.length > filtered.length && (
<div className="px-3 py-2 text-[11px] text-muted-foreground border-t border-border bg-secondary/30">
Visar {filtered.length} av {glLines.length} — sök för att filtrera fler.
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
// ============================================================
// Component
// ============================================================
@@ -103,16 +265,47 @@ export function BankReconciliationView() {
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [accountNumber, setAccountNumber] = useState('1930')
const [cashAccounts, setCashAccounts] = useState<CashAccount[]>([])
const [dryRunResults, setDryRunResults] = useState<DryRunMatch[] | null>(null)
const [runLoading, setRunLoading] = useState(false)
const [applyLoading, setApplyLoading] = useState(false)
const [linkLoading, setLinkLoading] = useState<string | null>(null)
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [showMatched, setShowMatched] = useState(false)
// Default expanded so users discover the undo path. The card itself only
// renders when ignoredTx.length > 0 — collapsing it by default hid the
// recovery affordance from anyone who didn't already know it was there.
const [showIgnored, setShowIgnored] = useState(true)
const [ignoredTx, setIgnoredTx] = useState<UnmatchedTransaction[]>([])
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
const { toast } = useToast()
// Derive the currency for the selected ledger account from cash_accounts.
// Without this the lists below would hardcode SEK and silently return zero
// rows for users on 1932 EUR (or any other non-SEK cash account).
const accountCurrency =
cashAccounts.find((a) => a.ledger_account === accountNumber)?.currency ?? 'SEK'
useEffect(() => {
let cancelled = false
fetch('/api/cash-accounts')
.then((r) => r.json())
.then((j) => {
if (!cancelled && Array.isArray(j.data)) setCashAccounts(j.data as CashAccount[])
})
.catch(() => {
// Non-critical — falls back to 'SEK' currency, matches old behaviour.
})
return () => {
cancelled = true
}
}, [])
const fetchAll = useCallback(async () => {
setLoading(true)
setError(null)
@@ -123,11 +316,19 @@ export function BankReconciliationView() {
params.set('account_number', accountNumber)
const qs = `?${params}`
const txParams = new URLSearchParams()
txParams.set('currency', accountCurrency)
txParams.set('account_number', accountNumber)
if (dateFrom) txParams.set('date_from', dateFrom)
if (dateTo) txParams.set('date_to', dateTo)
const unmatchedQs = `?unmatched=true&${txParams}`
const reconciledQs = `?reconciled=true&${txParams}`
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
fetch(`/api/reconciliation/bank/status${qs}`),
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`),
fetch(`/api/transactions?unmatched=true&currency=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
fetch(`/api/transactions?reconciled=true&currency=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
fetch(`/api/transactions${unmatchedQs}`),
fetch(`/api/transactions${reconciledQs}`),
])
const [statusData, glData, unmatchedData, matchedData] = await Promise.all([
@@ -141,13 +342,27 @@ export function BankReconciliationView() {
setGlLines(glData.data || [])
setUnmatchedTx(unmatchedData.data || [])
setMatchedTx(matchedData.data || [])
// Refresh the ignored list whenever the main lists refresh.
// Deliberately NOT filtered by account or currency — if a user ignored
// a row on 1932 EUR and then switched to 1930 SEK, the recovery card
// would disappear and the row would feel "stuck". Company-wide scope
// keeps the Återställ path reachable from any account selection. The
// date filter is also dropped so old ignores stay visible.
try {
const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`)
const ignoredData = await ignoredRes.json()
setIgnoredTx(ignoredData.data || [])
} catch {
setIgnoredTx([])
}
} catch (e) {
console.error('[reconciliation] fetchAll failed', e)
setError('Kunde inte hämta avstämningsdata')
} finally {
setLoading(false)
}
}, [dateFrom, dateTo, accountNumber])
}, [dateFrom, dateTo, accountNumber, accountCurrency])
useEffect(() => {
fetchAll()
@@ -253,6 +468,106 @@ export function BankReconciliationView() {
}
}
/**
* Inline shortcut for the most common "stuck on the unmatched list" cause:
* a small ränteintäkt that has no upstream voucher to match against. Calls
* the standard categorize endpoint with the existing bank_interest_income
* template so the resulting verifikation is identical to the /transactions
* flow — no parallel booking path.
*/
const handleBookInterestIncome = async (transactionId: string) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_business: true,
template_id: 'bank_interest_income',
confirm_no_match: true,
}),
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error?.message || result.error || 'Kunde inte bokföra ränteintäkten')
return
}
if (result.journal_entry_error) {
setError(result.journal_entry_error)
return
}
await fetchAll()
} catch {
setError('Kunde inte bokföra ränteintäkten')
} finally {
setActionLoading(null)
}
}
const handleIgnore = async (tx: UnmatchedTransaction) => {
// Even though Ignorera is fully reversible, it's still a state change the
// user could miss after a misclick — the row vanishes from the unmatched
// list immediately. Confirmation before the write + an explicit Ångra
// toast on success gives two recovery affordances. The persistent
// "Ignorerade transaktioner" card is the third.
const ok = await confirm({
title: 'Ignorera transaktionen?',
description: `${tx.description} — ${formatCurrency(tx.amount)} (${formatDate(tx.date)}) försvinner från avstämningen utan att bokföras. Du kan återställa den från "Ignorerade transaktioner" nedan när som helst.`,
confirmLabel: 'Ignorera',
cancelLabel: 'Avbryt',
variant: 'warning',
})
if (!ok) return
setActionLoading(tx.id)
try {
const res = await fetch(`/api/transactions/${tx.id}/ignore`, {
method: 'POST',
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error || 'Kunde inte ignorera transaktionen')
return
}
await fetchAll()
toast({
title: 'Transaktionen ignorerad',
description: `${tx.description} — ${formatCurrency(tx.amount)}`,
action: (
<ToastAction
altText="Ångra ignorera"
onClick={() => handleUnignore(tx.id)}
>
Ångra
</ToastAction>
),
})
} catch {
setError('Kunde inte ignorera transaktionen')
} finally {
setActionLoading(null)
}
}
const handleUnignore = async (transactionId: string) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/ignore`, {
method: 'DELETE',
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error || 'Kunde inte återställa transaktionen')
return
}
await fetchAll()
} catch {
setError('Kunde inte återställa transaktionen')
} finally {
setActionLoading(null)
}
}
if (loading) {
return (
<Card>
@@ -293,7 +608,7 @@ export function BankReconciliationView() {
<Card className="border-2">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Avstämning mot <AccountNumber number="1930" /></CardTitle>
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
{status.is_reconciled ? (
<Badge className="bg-success/10 text-success">Avstämd</Badge>
) : (
@@ -301,7 +616,7 @@ export function BankReconciliationView() {
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">
Endast konto <AccountNumber number="1930" /> ingår i denna avstämning. Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) måste avstämmas separat.
Avstämningen körs mot <AccountNumber number={accountNumber} /> ({accountCurrency}). Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) stäms av separat — välj kontot i listan nedan.
</p>
</CardHeader>
<CardContent>
@@ -311,7 +626,7 @@ export function BankReconciliationView() {
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
</div>
<div className="flex justify-between">
<span>Bokfört på <AccountNumber number="1930" /> i perioden</span>
<span>Bokfört på <AccountNumber number={accountNumber} /> i perioden</span>
<span className="font-mono">
{formatCurrency(status.gl_1930_period_movement)}
</span>
@@ -324,7 +639,7 @@ export function BankReconciliationView() {
</div>
{status.gl_1930_opening_balance !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Ingående balans (IB) på <AccountNumber number="1930" />:{' '}
Ingående balans (IB) på <AccountNumber number={accountNumber} />:{' '}
<span className="font-mono">{formatCurrency(status.gl_1930_opening_balance)}</span>
{' '}— räknas inte i avstämningen.
</p>
@@ -439,69 +754,141 @@ export function BankReconciliationView() {
{/* Unmatched Transactions */}
{unmatchedTx.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
<section className="space-y-3">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Omatchade transaktioner ({unmatchedTx.length})
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-24">Referens</th>
<th className="py-2 w-64">Föreslå verifikation</th>
<th className="py-2 w-24"></th>
</tr>
</thead>
<tbody>
{unmatchedTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
<td className="py-2">
<select
value={selectedMatch[tx.id] || ''}
onChange={(e) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: e.target.value }))
}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs"
</h2>
{glLines.length > 0 && (
<p className="text-xs text-muted-foreground">
{glLines.length} verifikation{glLines.length === 1 ? '' : 'er'} att matcha mot
</p>
)}
</div>
<div className="space-y-3">
{unmatchedTx.map((tx) => {
// Piggy-bank shortcut hardcodes the 1930↔8310 ränteintäkt template,
// so only offer it on a SEK account using 1930. On EUR (1932) or
// other settlement accounts the booking would post the EUR amount
// to the SEK cash account — silently wrong, hide it.
const canBookInterest = tx.amount > 0 && accountNumber === '1930'
const isPositive = tx.amount > 0
return (
<div
key={tx.id}
className="rounded-lg border border-border bg-card p-4 space-y-4"
>
{/* Header row: meta + description + amount + menu */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground tabular-nums">
<span>{formatDate(tx.date)}</span>
<span aria-hidden>·</span>
<Badge variant="outline" className="text-[10px] uppercase tracking-wider">
{tx.currency}
</Badge>
{tx.reference && (
<>
<span aria-hidden>·</span>
<span>Ref: {tx.reference}</span>
</>
)}
</div>
<div className="mt-1.5 text-sm font-medium truncate">{tx.description}</div>
</div>
<div className="flex items-start gap-2 shrink-0">
<div
className={`font-display text-xl tabular-nums ${
isPositive ? 'text-success' : ''
}`}
>
<option value="">Välj verifikation...</option>
{glLines.map((line) => {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<option key={line.line_id} value={line.journal_entry_id}>
{formatVoucher(line)} | {formatDate(line.entry_date)} | {formatCurrency(lineAmount)} | {line.entry_description}
</option>
)
})}
</select>
</td>
<td className="py-2">
{isPositive ? '+' : ''}
{formatCurrency(tx.amount)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
aria-label="Fler åtgärder"
disabled={actionLoading === tx.id}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{canBookInterest && (
<DropdownMenuItem
onClick={() => handleBookInterestIncome(tx.id)}
disabled={actionLoading === tx.id}
>
<PiggyBank className="h-4 w-4" />
<div className="flex flex-col">
<span>Bokför som ränteintäkt</span>
<span className="text-xs text-muted-foreground">
1930 mot 8310, ingen moms
</span>
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleIgnore(tx)}
disabled={actionLoading === tx.id}
>
<EyeOff className="h-4 w-4" />
<div className="flex flex-col">
<span>Ignorera transaktion…</span>
<span className="text-xs text-muted-foreground">
Dölj utan att bokföra. Går att återställa.
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Match action row */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-[11px] uppercase tracking-wider text-muted-foreground">
Matcha mot verifikation
</Label>
{glLines.length === 0 && (
<span className="text-[11px] text-muted-foreground">
Inga omatchade verifikationer på <AccountNumber number={accountNumber} />
</span>
)}
</div>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<MatchVerifikationPicker
glLines={glLines}
value={selectedMatch[tx.id] || ''}
onChange={(v) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: v }))
}
disabled={linkLoading === tx.id || glLines.length === 0}
/>
</div>
<Button
size="sm"
variant="outline"
disabled={!selectedMatch[tx.id] || linkLoading === tx.id}
onClick={() => handleManualLink(tx.id)}
className="shrink-0 h-10"
>
<Link2 className="h-3 w-3 mr-1" />
{linkLoading === tx.id ? '...' : 'Matcha'}
<Link2 className="h-3.5 w-3.5 mr-1.5" />
{linkLoading === tx.id ? 'Matchar…' : 'Matcha'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
</div>
</div>
)
})}
</div>
</section>
)}
{/* Unmatched GL Lines */}
@@ -509,7 +896,7 @@ export function BankReconciliationView() {
<Card>
<CardHeader>
<CardTitle className="text-lg">
Omatchade verifikationer på <AccountNumber number="1930" /> ({glLines.length})
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({glLines.length})
</CardTitle>
</CardHeader>
<CardContent>
@@ -548,6 +935,69 @@ export function BankReconciliationView() {
</Card>
)}
{/* Ignored transactions (undo) */}
{ignoredTx.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer"
onClick={() => setShowIgnored(!showIgnored)}
>
<div className="flex items-center gap-2">
{showIgnored ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-lg">
Ignorerade transaktioner ({ignoredTx.length})
</CardTitle>
</div>
</CardHeader>
{showIgnored && (
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
Rader du valt att dölja från avstämningen. De påverkar inte saldot på <AccountNumber number={accountNumber} /> — de är bara gömda från listan.
</p>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-20">Valuta</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-28"></th>
</tr>
</thead>
<tbody>
{ignoredTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0 text-muted-foreground">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className="py-2 text-xs">
<Badge variant="outline" className="text-xs">{tx.currency}</Badge>
</td>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2">
<Button
size="sm"
variant="ghost"
disabled={actionLoading === tx.id}
onClick={() => handleUnignore(tx.id)}
>
{actionLoading === tx.id ? '...' : 'Återställ'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)}
{/* Recently Matched */}
{matchedTx.length > 0 && (
<Card>
@@ -616,13 +1066,15 @@ export function BankReconciliationView() {
)}
{/* Empty state */}
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && !loading && (
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga transaktioner eller verifikationer att stämma av.
</CardContent>
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}
+11 -8
View File
@@ -22,6 +22,7 @@ import {
Heart,
HeartPulse,
Loader2,
MinusCircle,
Trash2,
X,
type LucideIcon,
@@ -48,6 +49,7 @@ type AbsenceType =
| 'pregnancy'
| 'care_relative'
| 'study'
| 'unpaid_leave'
| 'other_leave'
interface AbsenceDay {
@@ -73,16 +75,17 @@ interface AbsenceTypeMeta {
}
const TYPE_META: Record<AbsenceType, AbsenceTypeMeta> = {
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' },
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, pillClass: 'bg-red-100 text-red-800' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, pillClass: 'bg-amber-100 text-amber-800' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, pillClass: 'bg-emerald-100 text-emerald-800' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, pillClass: 'bg-pink-100 text-pink-800' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, pillClass: 'bg-blue-100 text-blue-800' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, pillClass: 'bg-indigo-100 text-indigo-800' },
unpaid_leave: { label: 'Tjänstledig utan lön', shortLabel: 'Tjänstl.', icon: MinusCircle, pillClass: 'bg-slate-100 text-slate-800' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, pillClass: 'bg-zinc-100 text-zinc-800' },
}
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'other_leave']
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave']
// ─── Component ─────────────────────────────────────────────────────
+23 -4
View File
@@ -63,6 +63,12 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
if (customerError) throw customerError
if (cancelled) return
// Non-momsregistrerade säljare ska inte få en exempel-rad med 25 %
// VAT — förhandsvisningen är hårdkodad sample-data, inte ett val
// användaren gjort, så vi följer settings.vat_registered direkt här
// (till skillnad från /invoices/new som låter användaren välja och
// bara varnar vid submit).
const previewVatRate = settings.vat_registered === false ? 0 : 25
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -77,7 +83,7 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
quantity: 1,
unit: 'st',
unit_price: 1000,
vat_rate: 25,
vat_rate: previewVatRate,
},
],
}),
@@ -147,11 +153,24 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
)}
{!isLoading && !error && blobUrl && (
<iframe
src={blobUrl}
// <object> + type="application/pdf" invokes Chrome's PDF plugin
// directly. <iframe> went through Chrome's frame pipeline first
// and intermittently surfaced "Det här innehållet har blockerats"
// even with a permissive CSP. See AttachmentPreviewSheet.tsx for
// the same workaround on journal entry attachments.
<object
data={blobUrl}
type="application/pdf"
title={t('iframe_title')}
className="w-full h-[70vh] rounded-lg border border-border"
/>
>
<p className="p-4 text-sm text-muted-foreground">
{t('error')}:{' '}
<a href={blobUrl} target="_blank" rel="noreferrer" className="underline">
{t('iframe_title')}
</a>
</p>
</object>
)}
</DialogContent>
</Dialog>
@@ -15,7 +15,7 @@ import { Loader2 } from 'lucide-react'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, JournalEntrySourceType } from '@/types'
const SERIES_OPTIONS = 'ABCDEFG'.split('')
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
// Subset of source_types presented to the user. The DB column accepts every
// JournalEntrySourceType, but several values (storno, correction, etc.) are
@@ -0,0 +1,138 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Loader2, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
interface ActiveConnection {
id: string
bank_name: string
}
/**
* On-demand "Sync now" button beside BankSyncStatusChip. Reuses the
* per-connection sync endpoint that BankingSettingsPanel already calls;
* if the user has multiple active connections, a dropdown lets them
* pick which one to sync.
*/
export default function BankSyncNowButton() {
const t = useTranslations('transactions')
const { toast } = useToast()
const router = useRouter()
const { company } = useCompany()
const [connections, setConnections] = useState<ActiveConnection[] | null>(null)
const [syncingId, setSyncingId] = useState<string | null>(null)
useEffect(() => {
if (!company?.id) return
let cancelled = false
const supabase = createClient()
supabase
.from('bank_connections')
.select('id, bank_name')
.eq('company_id', company.id)
.eq('status', 'active')
.then(({ data }) => {
if (!cancelled) setConnections(data ?? [])
})
return () => {
cancelled = true
}
}, [company?.id])
if (!connections || connections.length === 0) return null
async function syncConnection(connectionId: string) {
setSyncingId(connectionId)
try {
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Sync failed')
}
toast({
title: t('bank_sync_button_now'),
description: data.imported === 1
? t('bank_sync_new_since_last_visit_one')
: t('bank_sync_new_since_last_visit_many', { count: data.imported ?? 0 }),
})
router.refresh()
} catch (error) {
toast({
title: t('bank_sync_button_now'),
description: error instanceof Error ? error.message : 'Sync failed',
variant: 'destructive',
})
} finally {
setSyncingId(null)
}
}
const isSyncing = syncingId !== null
const label = isSyncing ? t('bank_sync_button_syncing') : t('bank_sync_button_now')
if (connections.length === 1) {
return (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={isSyncing}
onClick={() => syncConnection(connections[0].id)}
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{connections.map((conn) => (
<DropdownMenuItem
key={conn.id}
disabled={isSyncing}
onSelect={() => syncConnection(conn.id)}
>
{conn.bank_name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,92 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Sparkles, X } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
/**
* One-time pill telling the user how many new PSD2-synced transactions
* have arrived since they last visited /transactions. Helps make the
* nightly cron visible without polling or pushing notifications.
*
* State lives in localStorage keyed per company. On mount:
* - If no previous visit recorded: store now, render nothing.
* - Else: count rows added since lastVisit. If > 0, show the pill.
* - On dismiss: write `now` to localStorage and hide.
*/
export default function BankSyncSinceLastVisit() {
const t = useTranslations('transactions')
const { company } = useCompany()
const [count, setCount] = useState<number | null>(null)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
if (!company?.id) return
if (typeof window === 'undefined') return
const storageKey = `gnubok.lastTransactionsVisit.${company.id}`
const lastVisitRaw = window.localStorage.getItem(storageKey)
const now = new Date().toISOString()
if (!lastVisitRaw) {
window.localStorage.setItem(storageKey, now)
return
}
let cancelled = false
const supabase = createClient()
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', company.id)
.eq('import_source', 'enable_banking')
.gt('created_at', lastVisitRaw)
.then(({ count: rowCount }) => {
if (cancelled) return
if (rowCount && rowCount > 0) {
setCount(rowCount)
} else {
// Nothing new — refresh the timestamp so we don't keep checking
// the same window forever.
window.localStorage.setItem(storageKey, now)
}
})
return () => {
cancelled = true
}
}, [company?.id])
if (dismissed || !count || count <= 0) return null
function handleDismiss() {
if (typeof window === 'undefined') return
if (company?.id) {
window.localStorage.setItem(
`gnubok.lastTransactionsVisit.${company.id}`,
new Date().toISOString(),
)
}
setDismissed(true)
}
return (
<div className="inline-flex items-center gap-2 rounded-md border border-success/30 bg-success/5 px-2.5 py-1 text-xs text-success">
<Sparkles className="h-3.5 w-3.5" />
<span>
{count === 1
? t('bank_sync_new_since_last_visit_one')
: t('bank_sync_new_since_last_visit_many', { count })}
</span>
<button
type="button"
onClick={handleDismiss}
aria-label={t('bank_sync_new_since_last_visit_dismiss')}
className="ml-1 rounded-sm p-0.5 opacity-70 transition-opacity hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
)
}
+80 -24
View File
@@ -6,6 +6,12 @@ import { useTranslations } from 'next-intl'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/info-tooltip'
interface ConnectionRow {
id: string
@@ -13,6 +19,37 @@ interface ConnectionRow {
last_synced_at: string | null
}
const STALE_THRESHOLD_MS = 36 * 60 * 60 * 1000
type ChipState =
| { kind: 'none' }
| { kind: 'attention'; count: number }
| { kind: 'stale'; mostRecent: string }
| { kind: 'healthy'; mostRecent: string | null }
export function getChipState(rows: ConnectionRow[], now: number = Date.now()): ChipState {
if (rows.length === 0) return { kind: 'none' }
const needsAttention = rows.filter(
(r) => r.status === 'expired' || r.status === 'error',
)
if (needsAttention.length > 0) {
return { kind: 'attention', count: needsAttention.length }
}
const mostRecent = rows
.map((r) => r.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop()
if (mostRecent && now - new Date(mostRecent).getTime() > STALE_THRESHOLD_MS) {
return { kind: 'stale', mostRecent }
}
return { kind: 'healthy', mostRecent: mostRecent ?? null }
}
function useAgeFormatter() {
const t = useTranslations('transactions')
return (iso: string): string => {
@@ -49,13 +86,13 @@ export default function BankSyncStatusChip() {
}
}, [company?.id])
if (!rows || rows.length === 0) return null
if (!rows) return null
const needsAttention = rows.filter(
(r) => r.status === 'expired' || r.status === 'error',
)
const state = getChipState(rows)
if (needsAttention.length > 0) {
if (state.kind === 'none') return null
if (state.kind === 'attention') {
return (
<Link
href="/settings/banking"
@@ -63,32 +100,51 @@ export default function BankSyncStatusChip() {
>
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{needsAttention.length === 1
{state.count === 1
? t('bank_sync_attention_one')
: t('bank_sync_attention_many', { count: needsAttention.length })}
: t('bank_sync_attention_many', { count: state.count })}
</span>
</Link>
)
}
const mostRecent = rows
.map((r) => r.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop()
if (state.kind === 'stale') {
return (
<Link
href="/settings/banking"
className="inline-flex items-center gap-1.5 rounded-md border border-warning/40 bg-warning/5 px-2.5 py-1 text-xs text-warning transition-colors hover:bg-warning/10"
>
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{t('bank_sync_stale_warning')}
<span className="ml-1 tabular-nums opacity-70">({formatAge(state.mostRecent)})</span>
</span>
</Link>
)
}
// healthy
return (
<div className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5" />
<span>
{t('bank_sync_auto_nightly')}
{mostRecent && (
<>
{t('bank_sync_last_separator')}
<span className="tabular-nums">{formatAge(mostRecent)}</span>
</>
)}
</span>
</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5" />
<span>
{t('bank_sync_auto_nightly')}
{state.mostRecent && (
<>
{t('bank_sync_last_separator')}
<span className="tabular-nums">{formatAge(state.mostRecent)}</span>
</>
)}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[320px]">
<div className="text-sm leading-relaxed">{t('bank_sync_latency_hint')}</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+354 -25
View File
@@ -1,12 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CheckCircle2, AlertTriangle } from 'lucide-react'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { CheckCircle2, AlertTriangle, Trash2, Plus, Pencil } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
import type { BASAccount } from '@/types'
interface DuplicateCandidate {
journal_entry_id: string
@@ -18,15 +21,72 @@ interface DuplicateCandidate {
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
}
interface PreviewLine {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
interface MatchPreview {
entry_type: 'clearing' | 'cash'
lines: PreviewLine[]
invoice_already_booked: boolean
accounting_method: 'accrual' | 'cash'
is_fully_paid: boolean
}
// String-typed working copy of a line. The amount is a single value plus a
// side (debit / credit) — modeling a verifikationsrad as one positive number
// with a direction matches how Swedish accountants think and tightens the
// failure modes (you can't accidentally fill both sides). Conversion back
// to the server's { debit_amount, credit_amount } shape happens at submit.
interface EditableLine {
account_number: string
side: 'debit' | 'credit'
amount: string
description: string
}
export interface ConfirmOpts {
force?: boolean
expected_journal_entry_id?: string
lines?: Array<{
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
}>
}
interface InvoiceMatchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: (opts?: { force?: boolean; expected_journal_entry_id?: string }) => void
onConfirm: (opts?: ConfirmOpts) => void
onLinkToExisting?: (journalEntryId: string) => void
}
function previewToEditable(line: PreviewLine): EditableLine {
const isDebit = line.debit_amount > 0
return {
account_number: line.account_number,
side: isDebit ? 'debit' : 'credit',
amount: String(isDebit ? line.debit_amount : line.credit_amount),
description: line.description,
}
}
function parseAmount(s: string): number {
const n = Number(s.replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
export default function InvoiceMatchDialog({
open,
onOpenChange,
@@ -40,13 +100,80 @@ export default function InvoiceMatchDialog({
const isCustomerInvoice = !!transaction?.potential_invoice
const transactionId = transaction?.id ?? null
// Customer-side only: pre-flight check for a manual verifikation that
// already books this receipt. Supplier-side duplicate-payment surfacing
// is handled by the mark-paid guard on the supplier-invoice side; here
// we only need the customer flow for the reported issue.
const [candidate, setCandidate] = useState<DuplicateCandidate | null>(null)
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false)
const invoiceId = transaction?.potential_invoice?.id ?? null
const supplierInvoiceId = transaction?.potential_supplier_invoice?.id ?? null
const [preview, setPreview] = useState<MatchPreview | null>(null)
const [previewFailed, setPreviewFailed] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editLines, setEditLines] = useState<EditableLine[]>([])
// BAS accounts power the AccountCombobox suggestions in edit mode. Loaded
// once on dialog open; same endpoint that PaymentBookingDialog uses.
const [accounts, setAccounts] = useState<BASAccount[]>([])
useEffect(() => {
if (!open) return
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/bookkeeping/accounts')
if (!res.ok) return
const data = await res.json()
if (!cancelled) setAccounts((data?.data as BASAccount[]) ?? [])
} catch {
// Non-fatal: combobox just shows no suggestions, user can still
// type the number manually.
}
})()
return () => {
cancelled = true
}
}, [open])
useEffect(() => {
if (!open || !transactionId) {
setPreview(null)
setPreviewFailed(false)
setIsEditing(false)
setEditLines([])
return
}
let cancelled = false
const previewUrl = isCustomerInvoice && invoiceId
? `/api/transactions/${transactionId}/match-invoice/preview?invoice_id=${invoiceId}`
: isSupplierInvoice && supplierInvoiceId
? `/api/transactions/${transactionId}/match-supplier-invoice/preview?supplier_invoice_id=${supplierInvoiceId}`
: null
if (!previewUrl) {
setPreview(null)
setPreviewFailed(false)
return
}
async function loadPreview() {
setPreviewFailed(false)
try {
const res = await fetch(previewUrl!)
if (!res.ok) {
if (!cancelled) setPreviewFailed(true)
return
}
const data = (await res.json()) as MatchPreview
if (!cancelled) {
setPreview(data)
setEditLines(data.lines.map(previewToEditable))
}
} catch {
if (!cancelled) setPreviewFailed(true)
}
}
loadPreview()
return () => {
cancelled = true
}
}, [open, transactionId, isCustomerInvoice, isSupplierInvoice, invoiceId, supplierInvoiceId])
useEffect(() => {
if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) {
setCandidate(null)
@@ -72,10 +199,59 @@ export default function InvoiceMatchDialog({
}
}, [open, transactionId, isCustomerInvoice, onLinkToExisting])
// The invoice candidate the dialog is about, normalized to a single shape.
// Supplier invoices show the negative-amount paid-out match; customer
// invoices show the positive-amount paid-in match. Each side carries its
// own follow-up action language.
// Live balance + validity. The dialog disables Confirm while edit mode is
// active and the entry is invalid; an out-of-balance entry can't be sent.
const editValidation = useMemo(() => {
if (!isEditing) return { isBalanced: true, isValid: true, diff: 0, totalDebit: 0, totalCredit: 0, accountInvalid: false }
const totalDebit = round2(
editLines.filter((l) => l.side === 'debit').reduce((s, l) => s + parseAmount(l.amount), 0),
)
const totalCredit = round2(
editLines.filter((l) => l.side === 'credit').reduce((s, l) => s + parseAmount(l.amount), 0),
)
const isBalanced = totalDebit === totalCredit && totalDebit > 0
const accountInvalid = editLines.some((l) => !/^\d{4}$/.test(l.account_number.trim()))
return {
isBalanced,
accountInvalid,
isValid: isBalanced && !accountInvalid,
diff: round2(totalDebit - totalCredit),
totalDebit,
totalCredit,
}
}, [isEditing, editLines])
const handleConfirm = (opts?: { force?: boolean; expected_journal_entry_id?: string }) => {
const linesPayload = isEditing && preview && editValidation.isValid
? editLines.map((l) => {
const amount = round2(parseAmount(l.amount))
return {
account_number: l.account_number.trim(),
debit_amount: l.side === 'debit' ? amount : 0,
credit_amount: l.side === 'credit' ? amount : 0,
line_description: l.description?.trim() || undefined,
}
})
: undefined
onConfirm({ ...(opts ?? {}), ...(linesPayload ? { lines: linesPayload } : {}) })
}
const resetEdits = () => {
if (preview) setEditLines(preview.lines.map(previewToEditable))
}
const addEditLine = () => {
setEditLines((prev) => [...prev, { account_number: '', side: 'debit', amount: '', description: '' }])
}
const removeEditLine = (i: number) => {
setEditLines((prev) => prev.filter((_, idx) => idx !== i))
}
const updateEditLine = (i: number, patch: Partial<EditableLine>) => {
setEditLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
}
const matchTitle = isSupplierInvoice ? t('title_supplier') : t('title_customer')
const matchDescription = isSupplierInvoice
? t('description_supplier')
@@ -83,7 +259,7 @@ export default function InvoiceMatchDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{matchTitle}</DialogTitle>
<DialogDescription>{matchDescription}</DialogDescription>
@@ -111,13 +287,6 @@ export default function InvoiceMatchDialog({
})}
</p>
{candidate.description && (
// Truncate to a short head before render. The
// description is free-text and may carry a customer
// name or note that's not strictly required to
// identify the verifikation (voucher_label + amount +
// date already do that). Cap length to keep the
// dialog tight and limit incidental PII surfacing
// in the rendered DOM. GDPR Art.5(1)(c).
<p className="text-xs text-muted-foreground truncate">
{candidate.description.length > 80
? `${candidate.description.slice(0, 80).trimEnd()}…`
@@ -141,11 +310,8 @@ export default function InvoiceMatchDialog({
variant="outline"
size="sm"
onClick={() =>
onConfirm({
handleConfirm({
force: true,
// Echo the candidate the user reviewed back to
// the server so the bypass is bound to this
// specific duplicate. See match-invoice route.
expected_journal_entry_id: candidate.journal_entry_id,
})
}
@@ -256,6 +422,166 @@ export default function InvoiceMatchDialog({
)
})()}
{/* Bookkeeping preview — editable. Read-only by default; user
clicks "Redigera" to switch the rows to inputs. */}
{(preview || previewFailed) && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t('booking_title')}</p>
{preview && (
<div className="flex gap-2">
{isEditing && (
<Button variant="ghost" size="sm" onClick={resetEdits} disabled={isConfirming}>
{t('booking_reset')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing((v) => !v)}
disabled={isConfirming}
>
{isEditing ? t('booking_done_editing') : (
<>
<Pencil className="h-3 w-3 mr-1" />
{t('booking_edit')}
</>
)}
</Button>
</div>
)}
</div>
{previewFailed && !preview && (
<p className="text-sm text-muted-foreground">{t('booking_unavailable')}</p>
)}
{preview && !isEditing && (
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 gap-y-1 text-sm tabular-nums">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('booking_account')}
</div>
<div />
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
{t('booking_debit')}
</div>
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
{t('booking_credit')}
</div>
{preview.lines.map((line, i) => (
<div key={i} className="contents">
<div className="font-medium">{line.account_number}</div>
<div className="text-muted-foreground truncate">{line.description}</div>
<div className="text-right">
{line.debit_amount > 0
? formatCurrency(line.debit_amount, transaction.currency)
: ''}
</div>
<div className="text-right">
{line.credit_amount > 0
? formatCurrency(line.credit_amount, transaction.currency)
: ''}
</div>
</div>
))}
</div>
)}
{preview && isEditing && (
<div className="space-y-2">
{editLines.map((line, i) => (
<div
key={i}
className="grid grid-cols-[minmax(180px,1.6fr)_minmax(0,1fr)_140px_110px_28px] gap-2 items-center"
>
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(acc) => updateEditLine(i, { account_number: acc })}
/>
<Input
value={line.description}
onChange={(e) => updateEditLine(i, { description: e.target.value })}
placeholder={t('booking_description_placeholder')}
/>
{/* Side toggle — segmented control. Clicking either
button picks that side; the amount stays the
same. */}
<div className="inline-flex rounded-md border bg-background overflow-hidden h-9">
<button
type="button"
onClick={() => updateEditLine(i, { side: 'debit' })}
className={cn(
'flex-1 px-2 text-xs font-medium transition-colors',
line.side === 'debit'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'debit'}
>
{t('booking_debit')}
</button>
<button
type="button"
onClick={() => updateEditLine(i, { side: 'credit' })}
className={cn(
'flex-1 px-2 text-xs font-medium border-l transition-colors',
line.side === 'credit'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'credit'}
>
{t('booking_credit')}
</button>
</div>
<Input
inputMode="decimal"
value={line.amount}
onChange={(e) => updateEditLine(i, { amount: e.target.value })}
className="text-right tabular-nums"
placeholder="0"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeEditLine(i)}
disabled={editLines.length <= 2}
aria-label={t('booking_remove_line')}
className="h-8 w-8"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center justify-between pt-1">
<Button variant="ghost" size="sm" onClick={addEditLine}>
<Plus className="h-3 w-3 mr-1" />
{t('booking_add_line')}
</Button>
<div className="text-xs tabular-nums text-muted-foreground">
{t('booking_debit')} {formatCurrency(editValidation.totalDebit, transaction.currency)}
{' / '}
{t('booking_credit')} {formatCurrency(editValidation.totalCredit, transaction.currency)}
</div>
</div>
{!editValidation.isBalanced && (
<p className="text-xs text-destructive">
{t('booking_unbalanced', {
diff: formatCurrency(Math.abs(editValidation.diff), transaction.currency),
})}
</p>
)}
{editValidation.accountInvalid && (
<p className="text-xs text-destructive">{t('booking_account_invalid')}</p>
)}
</div>
)}
</div>
)}
{/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
@@ -272,7 +598,10 @@ export default function InvoiceMatchDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
{t('cancel')}
</Button>
<Button onClick={() => onConfirm()} disabled={isConfirming || isCheckingDuplicate}>
<Button
onClick={() => handleConfirm()}
disabled={isConfirming || isCheckingDuplicate || (isEditing && !editValidation.isValid)}
>
{isConfirming ? t('confirming') : t('confirm_match')}
</Button>
</DialogFooter>