feat(transactions): split-payment allocator — 1 tx → N invoices (#603)
* fix(category-mapping): use leaf BAS accounts instead of group codes 3900, 5800, 6200 are BAS gruppkonton (header codes) and shouldn't carry postings. Switched the default mappings to the matching leaf accounts: - income_other: 3900 -> 3999 (Övriga rörelseintäkter) - expense_travel: 5800 -> 5890 (Övriga resekostnader) - expense_telecom: 6200 -> 6230 (Datakommunikation) The fallback for income_other inside getCategoryAccountMapping was also hardcoded to '3900'; updated to '3999' for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): split-payment allocator — 1 tx → N invoices Closes one of the two flows that motivated PR #602's foundation: allocating a single bank transaction across multiple customer OR multiple supplier invoices, with one combined verifikat (samlingsverifikation per BFL 5 kap 6§ st 3). ## Backend (Phase 3a) - **PL/pgSQL RPC** match_batch_allocate (~400 lines): locks the tx + each target invoice with SELECT … FOR UPDATE in id order, validates status/currency/remaining/direction before any write, builds the combined verifikat via commit_journal_entry (atomically assigns voucher_number + flips draft→posted), inserts N rows in invoice_payments or supplier_invoice_payments pointing at the same JE, advances paid_amount/remaining_amount/status per invoice. Returns { ok, journal_entry_id, voucher_number, allocations: [...] } on success or { ok: false, code, details } on guard failure. Mixed customer+supplier kinds are rejected (v1 scope). - **Endpoint** POST /api/transactions/[id]/match-batch — thin wrapper around the RPC. Validates body via MatchBatchSchema (zod discriminatedUnion + superRefine to catch mixed-kinds at the schema layer). On RPC success, emits one invoice.match_confirmed or supplier_invoice.match_confirmed event per allocation so existing subscribers (reminders, automations, processing-history) keep working. Maps the structured RPC error envelope to errorResponseFromCode. - **16 new BATCH_* error codes** (sv+en): BATCH_TX_NOT_FOUND, BATCH_TX_ALREADY_BOOKED, BATCH_OVERSHOOT, BATCH_AMOUNT_EXCEEDS_TX, BATCH_MIXED_KINDS_UNSUPPORTED, BATCH_DIRECTION_MISMATCH, BATCH_CURRENCY_MISMATCH, BATCH_PERIOD_LOCKED, BATCH_RPC_FAILED, etc. ## UI (Phase 5a) - **MatchAllocationDialog** (components/transactions/) — direction- aware (positive tx → customer invoices, negative → supplier). Search + selectable list of open invoices. Per-row amount input with default = min(invoice.remaining, tx_remaining_budget). Live tally with green-check balanced state, red overshoot warning, gray leftover note. Confirm button disabled on overshoot. POSTs to /match-batch and on 200 triggers the same exit animation as single-tx match. - **Inbox row** gains a second outline icon button (Split icon) next to the existing 1:1 match button, gated by the same showInvoiceMatchButton predicate. Tooltip explains the direction- aware split. Opens MatchAllocationDialog. - **i18n** strings under tx_match_allocation namespace in sv.json and en.json (32 keys each). ## Tests - tests/pg/match-batch-allocate.pg.test.ts — 5 pg-real tests covering combined verifikat shape, overshoot guard, already-booked tx, direction mismatch, mixed-kinds rejection. - app/api/transactions/[id]/match-batch/__tests__/route.test.ts — 5 unit tests covering schema validation, mixed-kinds, happy path, structured-error mapping, raw-error → BATCH_RPC_FAILED. 63 unit tests pass across the touched paths. The RPC migration was already applied to remote in an earlier Phase 3a session (idempotent CREATE OR REPLACE FUNCTION; the next replay is a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 1 + CI fixes Closes both CI failures and the three real review findings. ## CI fixes - **pg-real failure**: the RPC declared `v_journal_entry_id uuid := uuid_generate_v4()` which fails in the CI Postgres image (uuid-ossp extension is off). Switched to `gen_random_uuid()` — the codebase standard already used by supplier_invoices, invoice_inbox, etc. - **core-only failure**: my earlier BAS leaf-account commit (3900→3999, 5800→5890, 6200→6230) didn't update the matching `lib/bookkeeping/__tests__/category-mapping.test.ts` expectations, and `getDefaultAccountForCategory`'s fallback for `income_*` was still hardcoded to '3900'. Updated both. ## Review findings (greptile) - **P1 deadlock-stable locking** (`match_batch_allocate.sql:11`): the validation `FOR UPDATE` loop ran in caller-supplied array order. Two concurrent calls with overlapping invoice sets in opposite orders could deadlock and one would abort with `BATCH_RPC_FAILED`. Now all three loops (validate, build lines, advance invoices) iterate via `SELECT … FROM jsonb_array_elements(…) ORDER BY COALESCE(invoice_id, supplier_invoice_id)`, giving a stable global lock order regardless of how the caller ordered the JSON array. - **P1 duplicate-allocation detection** (`match_batch_allocate.sql:163`): the same invoice_id listed twice would pass the per-row overshoot guard (both iterations read the original `remaining_amount`) and the write loop would insert two `invoice_payments` rows for the same invoice. Added a `v_seen_ids text[]` check in the validation loop and a new `BATCH_DUPLICATE_ALLOCATION` error code (sv + en). The dialog already prevents this UI-side via `if (prev[candidate.id] return prev` — the RPC guard is the defense-in-depth layer. - **P2 zod `.positive()`** (`schemas.ts:544`): allocation amount was `nonNegativeAmount` (allowing 0), passing schema validation only to be rejected by the RPC with `BATCH_INVALID_AMOUNT`. Now `z.number().positive(…)` so 0-amount entries fail at the schema layer with a per-field path, cleaner 400. - **P2 strict `> 0` direction check** (`MatchAllocationDialog.tsx:82`): used `amount >= 0` to pick customer-side, but a zero-amount tx would load customer candidates only to hit `BATCH_TX_ZERO_AMOUNT` at submit time after the user has filled in allocations. Switched to `> 0` so 0-amount tx never reaches the dialog at all (it's rejected by the RPC immediately). The fourth Greptile comment (the schema P2 about amount validation) overlaps with the third; addressed in the same edit. ## Verification - 112 unit tests pass across touched paths - ESLint clean - New pg-real test `tests/pg/match-batch-allocate.pg.test.ts` covers the dedupe scenario (same supplier invoice listed twice with summing amounts that individually pass per-row overshoot) - RPC patch applied to remote via Supabase MCP Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 2 — compliance hardening Addresses the actionable findings from compliance-swarm and Swedish-accounting-compliance reviews. Six small RPC changes + two TS-side guards, all bundled in one follow-up migration. ## Security - **(GDPR Art.5(1)(f) / ISO A.8.2) Caller verification**: SECURITY DEFINER bypasses RLS, and the prior RPC accepted any (p_user_id, p_company_id) pair from the route. Now the function rejects with new `BATCH_UNAUTHORIZED` (sv+en, HTTP 403) if `auth.uid()` is not a member of `p_company_id`. Pattern lifted from `harden_invoice_number_rpcs` (#20260510140000). - **(OWASP V4.2) Allocation cap**: `MatchBatchSchema.allocations` now carries `.max(100)` to prevent DoS via unbounded FOR UPDATE locks. ## Swedish accounting correctness - **source_type per direction**: was hardcoded to `'invoice_paid'` for both customer + supplier batches, mis-routing behandlingshistorik filters. Customer batches keep `'invoice_paid'`, supplier batches now write `'supplier_invoice_paid'`. - **Fiscal-period determinism**: `LIMIT 1` on the period lookup was non-deterministic on overlap (e.g. corrected broken year). Added `ORDER BY period_start DESC` so the most recent matching period wins. - **Tolerance harmonisation**: cross-allocation sum used `+0.01` tolerance while per-row used `+0.005`. Both now `+0.005` so a multi-row batch can't drift ~0.01 SEK while each row passes individually. - **`transactions.category` no longer overwritten**: was forced to `'income_services'` (→ BAS 3001 at 25% VAT) for any customer batch, misrepresenting reduced-rate / export / EU-service invoices. The category is only meaningful 1:1 with a single invoice; batches now leave it as-is, mirroring the supplier-side `ELSE category` branch. ## Tests - `tests/pg/match-batch-allocate.pg.test.ts` now wraps every RPC call in `withUserContext(userId)` so `auth.uid()` resolves to the seeded owner. Without this the new membership check would have failed all existing tests. - New pg-real test: `rejects with BATCH_UNAUTHORIZED when caller is not a member of the company` — outsider user gets explicit refusal. - New happy-path assertion: `source_type = 'supplier_invoice_paid'` on the combined verifikat for supplier batches. 15 unit tests pass on the touched paths. RPC patch applied to remote via Supabase MCP. Out-of-scope mcp-server changes still parked locally. Skipped findings (documented in PR comment thread): - V8.2.1 ownership pre-check at route layer (RPC enforces it) - V4.5 / Art.5(1)(b) narrower API response and event payload — typed contracts require the full shapes - V2.4 rate-limiting — system-level, applies to all match endpoints - A.8.28 client-side RLS reliance — documented architectural choice - Direction pre-check at API layer (RPC catches with cleaner code) - V16 + Art.32 + Art.5(1)(b) low-severity logging nits Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- 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
7bcd46d503
commit
7eb8715417
@@ -0,0 +1,478 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||
import { Loader2, Search, X, Plus, Check, AlertTriangle } from 'lucide-react'
|
||||
import type { Invoice, Customer, SupplierInvoice, Supplier } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
|
||||
interface MatchAllocationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction-aware allocation candidate. The dialog normalizes customer and
|
||||
* supplier invoices to the same shape so the row renderer + tally math stay
|
||||
* a single code path. The `kind` discriminator drives the underlying API
|
||||
* payload at submit time.
|
||||
*/
|
||||
interface AllocationCandidate {
|
||||
kind: 'customer_invoice' | 'supplier_invoice'
|
||||
id: string
|
||||
label: string
|
||||
counterpartyName: string
|
||||
remaining: number
|
||||
total: number
|
||||
currency: string
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
type AllocationDraft = {
|
||||
candidateId: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
type CustomerInvoiceRow = Invoice & { customer?: Customer | null }
|
||||
type SupplierInvoiceRow = SupplierInvoice & { supplier?: Supplier | null }
|
||||
|
||||
function parseAmount(s: string): number {
|
||||
// Accept Swedish-style decimal comma + thousand spaces. Empty string → 0.
|
||||
const cleaned = s.replace(/\s+/g, '').replace(',', '.')
|
||||
const n = parseFloat(cleaned)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
export default function MatchAllocationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onSuccess,
|
||||
}: MatchAllocationDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const supabase = useMemo(() => createClient(), [])
|
||||
const t = useTranslations('tx_match_allocation')
|
||||
|
||||
const kind: 'customer_invoice' | 'supplier_invoice' = useMemo(() => {
|
||||
// Strict > 0 (was >= 0): a zero-amount tx would otherwise load customer
|
||||
// candidates and the RPC would reject with BATCH_TX_ZERO_AMOUNT after
|
||||
// the user has already filled in allocations. PR #603 review fix.
|
||||
return transaction && transaction.amount > 0 ? 'customer_invoice' : 'supplier_invoice'
|
||||
}, [transaction])
|
||||
|
||||
const [candidates, setCandidates] = useState<AllocationCandidate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [drafts, setDrafts] = useState<Record<string, AllocationDraft>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !transaction || !company) return
|
||||
const companyId = company.id
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (kind === 'customer_invoice') {
|
||||
// Mirror InvoicePicker's filter: only true invoices (no proformas)
|
||||
// in an open state with a positive remaining balance.
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.order('due_date', { ascending: true })
|
||||
if (cancelled) return
|
||||
const rows = (data ?? []) as CustomerInvoiceRow[]
|
||||
setCandidates(
|
||||
rows.map((r) => ({
|
||||
kind: 'customer_invoice',
|
||||
id: r.id,
|
||||
label: r.invoice_number ?? r.id.slice(0, 8),
|
||||
counterpartyName: r.customer?.name ?? t('unknown_customer'),
|
||||
remaining: Number(r.remaining_amount ?? r.total ?? 0),
|
||||
total: Number(r.total ?? 0),
|
||||
currency: r.currency,
|
||||
dueDate: r.due_date,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.order('due_date', { ascending: true })
|
||||
if (cancelled) return
|
||||
const rows = (data ?? []) as SupplierInvoiceRow[]
|
||||
setCandidates(
|
||||
rows.map((r) => ({
|
||||
kind: 'supplier_invoice',
|
||||
id: r.id,
|
||||
label: r.supplier_invoice_number ?? `LF-${r.arrival_number}`,
|
||||
counterpartyName: r.supplier?.name ?? t('unknown_supplier'),
|
||||
remaining: Number(r.remaining_amount ?? r.total ?? 0),
|
||||
total: Number(r.total ?? 0),
|
||||
currency: r.currency,
|
||||
dueDate: r.due_date,
|
||||
})),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, transaction, company, kind, supabase, t])
|
||||
|
||||
// Reset state every time the dialog re-opens for a new tx.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDrafts({})
|
||||
setSearch('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const txAmountAbs = transaction ? Math.abs(transaction.amount) : 0
|
||||
|
||||
const allocated = useMemo(() => {
|
||||
return Object.values(drafts).reduce((sum, d) => sum + parseAmount(d.amount), 0)
|
||||
}, [drafts])
|
||||
|
||||
const leftover = round2(txAmountAbs - allocated)
|
||||
const overshoot = leftover < -0.005
|
||||
const balanced = Math.abs(leftover) < 0.005 && Object.keys(drafts).length > 0
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
const selectedIds = new Set(Object.keys(drafts))
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const aSel = selectedIds.has(a.id)
|
||||
const bSel = selectedIds.has(b.id)
|
||||
if (aSel !== bSel) return aSel ? -1 : 1
|
||||
return a.dueDate.localeCompare(b.dueDate)
|
||||
})
|
||||
if (!search.trim()) return sorted
|
||||
const needle = search.trim().toLowerCase()
|
||||
return sorted.filter((c) => {
|
||||
const haystack = `${c.label} ${c.counterpartyName}`.toLowerCase()
|
||||
return haystack.includes(needle)
|
||||
})
|
||||
}, [candidates, drafts, search])
|
||||
|
||||
function addAllocation(candidate: AllocationCandidate) {
|
||||
setDrafts((prev) => {
|
||||
if (prev[candidate.id]) return prev
|
||||
const remainingTxBudget = Math.max(0, round2(txAmountAbs - allocated))
|
||||
const defaultAmount = Math.min(candidate.remaining, remainingTxBudget)
|
||||
return {
|
||||
...prev,
|
||||
[candidate.id]: {
|
||||
candidateId: candidate.id,
|
||||
amount: defaultAmount > 0 ? defaultAmount.toFixed(2).replace('.', ',') : '',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function removeAllocation(candidateId: string) {
|
||||
setDrafts((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[candidateId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setDraftAmount(candidateId: string, amount: string) {
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[candidateId]: { candidateId, amount },
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!transaction) return
|
||||
if (!balanced && !overshoot) {
|
||||
// Allow undershoot — the tx keeps its leftover unallocated. But reject
|
||||
// a no-allocation submit.
|
||||
if (Object.keys(drafts).length === 0) return
|
||||
}
|
||||
if (overshoot) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const allocations = Object.values(drafts)
|
||||
.map((d) => {
|
||||
const cand = candidates.find((c) => c.id === d.candidateId)
|
||||
if (!cand) return null
|
||||
const amount = parseAmount(d.amount)
|
||||
if (amount <= 0) return null
|
||||
return cand.kind === 'customer_invoice'
|
||||
? { kind: 'customer_invoice' as const, invoice_id: cand.id, amount }
|
||||
: { kind: 'supplier_invoice' as const, supplier_invoice_id: cand.id, amount }
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
|
||||
if (allocations.length === 0) {
|
||||
toast({
|
||||
title: t('error_no_allocations_title'),
|
||||
description: t('error_no_allocations_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/transactions/${transaction.id}/match-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allocations }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null)
|
||||
toast({
|
||||
title: t('error_submit_title'),
|
||||
description: getErrorMessage(body, {
|
||||
context: kind === 'customer_invoice' ? 'invoice' : 'supplier_invoice',
|
||||
statusCode: response.status,
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('success_title'),
|
||||
description: t('success_description', { count: allocations.length }),
|
||||
variant: 'success',
|
||||
})
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('error_submit_title'),
|
||||
description: getErrorMessage(err, {
|
||||
context: kind === 'customer_invoice' ? 'invoice' : 'supplier_invoice',
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kind === 'customer_invoice' ? t('description_customer') : t('description_supplier')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Transaction summary */}
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t('transaction_label')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">{transaction.description}</p>
|
||||
<div className="mt-1 flex items-center justify-between text-sm">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatDate(transaction.date)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium tabular-nums',
|
||||
transaction.amount > 0 && 'text-success',
|
||||
)}
|
||||
>
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Candidate list */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : filteredCandidates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm font-medium">{t('empty_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('empty_description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-[320px] overflow-y-auto">
|
||||
{filteredCandidates.map((c) => {
|
||||
const draft = drafts[c.id]
|
||||
const isSelected = !!draft
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-3 transition-colors',
|
||||
isSelected ? 'border-foreground' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium tabular-nums">{c.label}</span>
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
{t('selected_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{c.counterpartyName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{t('remaining_label', {
|
||||
amount: formatCurrency(c.remaining, c.currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={draft.amount}
|
||||
onChange={(e) => setDraftAmount(c.id, e.target.value)}
|
||||
className="h-9 w-28 font-mono text-right tabular-nums"
|
||||
aria-label={t('amount_input_aria', { label: c.label })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => removeAllocation(c.id)}
|
||||
aria-label={t('remove_aria', { label: c.label })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addAllocation(c)}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{t('add_button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Tally */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('allocated_label')}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono tabular-nums',
|
||||
overshoot && 'text-destructive',
|
||||
balanced && 'text-success',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(allocated, transaction.currency)} /{' '}
|
||||
{formatCurrency(txAmountAbs, transaction.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{overshoot ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<p>
|
||||
{t('overshoot_warning', {
|
||||
excess: formatCurrency(Math.abs(leftover), transaction.currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : balanced ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-success/10 p-3 text-sm text-success">
|
||||
<Check className="h-4 w-4 flex-shrink-0" />
|
||||
<p>{t('balanced_message')}</p>
|
||||
</div>
|
||||
) : leftover > 0.005 && Object.keys(drafts).length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('leftover_note', {
|
||||
amount: formatCurrency(leftover, transaction.currency),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting || overshoot || Object.keys(drafts).length === 0}
|
||||
>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FileText,
|
||||
Link2,
|
||||
Loader2,
|
||||
Split,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
@@ -47,6 +48,10 @@ interface TransactionInboxCardProps {
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the manual picker — routes to customer or supplier picker by amount sign. */
|
||||
onOpenMatchInvoicePicker: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the split-payment allocator (1 tx → N invoices) — same direction
|
||||
* detection as the single-pick picker. Optional so legacy callers stay
|
||||
* source-compatible. */
|
||||
onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
onToggleSelect: (id: string) => void
|
||||
@@ -61,6 +66,7 @@ export default function TransactionInboxCard({
|
||||
isSelected,
|
||||
onOpenMatchDialog,
|
||||
onOpenMatchInvoicePicker,
|
||||
onOpenSplitMatch,
|
||||
onOpenCategoryDialog,
|
||||
onDelete,
|
||||
onToggleSelect,
|
||||
@@ -178,6 +184,10 @@ export default function TransactionInboxCard({
|
||||
? 'Matcha mot kundfaktura'
|
||||
: 'Matcha mot leverantörsfaktura'
|
||||
|
||||
const splitMatchLabel = isIncome
|
||||
? 'Dela inbetalningen på flera fakturor'
|
||||
: 'Dela utbetalningen på flera leverantörsfakturor'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
@@ -257,6 +267,22 @@ export default function TransactionInboxCard({
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showInvoiceMatchButton && onOpenSplitMatch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenSplitMatch(transaction)
|
||||
}}
|
||||
aria-label={splitMatchLabel}
|
||||
title={splitMatchLabel}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<Split className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* The Paperclip indicator next to the description
|
||||
(TransactionAttachmentIndicator) is the single click
|
||||
target for opening the underlag. We deliberately don't
|
||||
|
||||
Reference in New Issue
Block a user