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}`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "{search}"
|
||||
</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¤cy=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
fetch(`/api/transactions?reconciled=true¤cy=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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user