feat(inbox): visible differens + double-click balance fill in Bokför direkt (#1544)

* feat(inbox): show differens in Bokfor direkt sum row + double-click balance fill

User feedback: when hand-computing VAT the unbalanced amount had to be
worked out manually; the existing diff indicator was tiny, muted and below
the fold. Now the remaining debit/credit gap renders red in the Summa row
(and the sums turn red) while unbalanced, matching JournalEntryForm.

Also ports JournalEntryForm's opt-in balancing: double-clicking a debit or
credit field fills the amount that balances the entry (no-op when balanced
or when the fill belongs on the other side), with a hint line replacing the
old indicator. Display-only + client-side prefill; the engine still rejects
unbalanced entries.

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

* refactor(inbox): use roundOre for balancing diff per antipattern guard

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 01:04:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 27383fd02b
commit a07dcf4a55
@@ -14,8 +14,9 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check, BookmarkPlus } from 'lucide-react'
import { Loader2, Plus, Trash2, Search, Check, BookmarkPlus } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
@@ -533,6 +534,37 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
setLines((prev) => prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx))
}, [])
// Outstanding imbalance from every line except `excludeIndex`.
// Positive => debit side is short (a debit on the target row balances it);
// negative => credit side is short. Same semantics as JournalEntryForm.
const computeBalancingDiff = useCallback(
(excludeIndex: number) => {
const others = lines.filter((_, i) => i !== excludeIndex)
const d = others.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const c = others.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
return roundOre(c - d)
},
[lines]
)
// Opt-in balancing (ported from JournalEntryForm): double-click a debit or
// credit field to fill the amount that makes the entry balance. No-op if
// already balanced or if the balancing entry belongs on the other side.
const handleFillBalance = useCallback(
(idx: number, side: 'debit' | 'credit') => {
const diff = computeBalancingDiff(idx)
const fill = side === 'debit' ? diff : -diff
if (fill <= 0) return
updateLine(
idx,
side === 'debit'
? { debit_amount: fill.toFixed(2), credit_amount: '' }
: { credit_amount: fill.toFixed(2), debit_amount: '' }
)
},
[computeBalancingDiff, updateLine]
)
// Replace the line set with a booking template's computed rows. The picker
// hands back JournalEntryForm-shaped lines; we keep only the three fields
// book-direct posts. A meaningful supplier description is preserved: the
@@ -879,6 +911,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
inputMode="decimal"
value={line.debit_amount}
onChange={(e) => updateLine(idx, { debit_amount: e.target.value, credit_amount: e.target.value ? '' : line.credit_amount })}
onDoubleClick={() => handleFillBalance(idx, 'debit')}
disabled={isSubmitting}
className="text-right tabular-nums"
placeholder="0,00"
@@ -891,6 +924,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
inputMode="decimal"
value={line.credit_amount}
onChange={(e) => updateLine(idx, { credit_amount: e.target.value, debit_amount: e.target.value ? '' : line.debit_amount })}
onDoubleClick={() => handleFillBalance(idx, 'credit')}
disabled={isSubmitting}
className="text-right tabular-nums"
placeholder="0,00"
@@ -914,13 +948,36 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
</tbody>
<tfoot className="bg-muted/20 text-xs">
<tr>
<td className="px-3 py-2 text-right font-medium uppercase tracking-wider text-muted-foreground">
Summa
<td className="px-3 py-2">
<div className="flex items-center justify-between gap-3">
{/* The remaining debit/credit gap, right where the user
reconciles the sums: what is still missing to balance. */}
{totals.diff !== 0 ? (
<span className="tabular-nums font-medium text-destructive">
Differens {Math.abs(totals.diff).toFixed(2)}
</span>
) : (
<span />
)}
<span className="text-right font-medium uppercase tracking-wider text-muted-foreground">
Summa
</span>
</div>
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
<td
className={cn(
'px-3 py-2 text-right tabular-nums font-medium',
totals.diff !== 0 && 'text-destructive'
)}
>
{totals.debit.toFixed(2)}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
<td
className={cn(
'px-3 py-2 text-right tabular-nums font-medium',
totals.diff !== 0 && 'text-destructive'
)}
>
{totals.credit.toFixed(2)}
</td>
<td />
@@ -969,12 +1026,11 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl =
<Badge variant="success" className="text-[11px]">
Balanserad
</Badge>
) : (
<span className="text-xs text-muted-foreground flex items-center gap-1.5 tabular-nums">
<AlertTriangle className="h-3.5 w-3.5 text-warning" />
Diff {totals.diff.toFixed(2)}
) : totals.diff !== 0 ? (
<span className="text-xs text-muted-foreground">
Dubbelklicka i ett tomt beloppsfält för att fylla i differensen
</span>
)}
) : null}
</div>
</div>