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:
co-authored by
Claude Opus 4.7
parent
fc7a46c3f2
commit
ea1bf01f1e
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user