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}`
}