* fix(supplier-invoices): duplicate-payment guard matches abbreviated bank text and shares one detector with the customer side The mark-paid guard probed merchant_name for the FULL supplier name, so the row that paid Hi3G Access AB (bank text "HI3G", merchant_name empty) never matched and the payment was booked twice (#2299). - counterpartyNeedle(): first distinctive token of the name (alnum, legal forms dropped, >= 2 chars so initialisms like SJ and 3M survive), probed on merchant_name OR description in one .or() per currency sweep; the alnum shape is what makes the DSL interpolation safe. - findDuplicatePaymentCandidatesForSupplierInvoice() beside the customer detector; both share the sweep and the scorer. The dashboard route's inline copy is deleted; the v1 supplier mark-paid door gets the guard it lacked. - New match_reason already_booked (row already carries a verifikat, booked straight from the bank side): ranked first, carries journal_entry_id, and the dialogs, MCP path and pending-operation commit word the remedy as a rattelse rather than "link it". - Customer side gets the same token prefilter and classification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * test(invoices): align customer mark-paid queued mocks with the one-probe duplicate guard The customer detector now issues one .or() counterparty probe per currency sweep instead of two ILIKE queries, so every queued answer after the guard was consumed one step early: the aggregate-sweep [] became company_settings, the settings row hit the entry builder, and two tests saw 500 / the wrong voucher id. Each guard block now enqueues one probe plus the aggregate sweep; the 409 tests drop the second-probe entry that is no longer read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): one logic expression per duplicate-payment sweep, never two or= params The sweep chain carried two .or() calls (currency clause, then name probe). postgrest-js appends a query parameter per call, so the client sent or= twice, and whether PostgREST ANDs a repeated key was never proven in this repo; had it kept one, the currency predicate would be gone and foreign rows banded against a kronor figure. counterpartySweepLogic() now nests both groups under one and() inside a single top-level or(): and(or(<currency>),or(merchant_name.ilike.*x*, description.ilike.*x*)). The sweep issues exactly one .or() per currency. Proof at three levels: unit tests pin the helper's string; a fake-fetch test runs the real postgrest-js builder and asserts exactly one or= search param per request; a tool-pg test seeds right-currency+hit, wrong-currency+hit (with an amount_sek that would pass every JS check) and right-currency+miss rows against a real PostgREST and asserts, for both detectors and both sweeps, that only the first comes back, from PostgREST's own response. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): name storno as the already_booked remedy, never "makulera" A posted verifikat is never deleted; it is corrected by a storno entry (BFL 5 kap 5 §). The already_booked remedy text in the error catalogue, the MCP and pending-operation messages and both UI descriptions now say so: "vänd en av verifikationerna med storno och koppla underlaget till den som blir kvar" / "reverse one of the two vouchers with a storno entry and attach the underlag to the remaining one". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
660 lines
26 KiB
TypeScript
660 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo } from 'react'
|
|
import { useAccounts, useCashAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
|
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
|
|
import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { formatCurrency, formatDate } from '@/lib/utils'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
|
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
|
import type { EntityType } from '@/types'
|
|
import type { InvoiceWithRelations } from '@/components/invoices/types'
|
|
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
|
|
|
type DuplicateMatchReason =
|
|
| 'ocr_exact'
|
|
| 'name_amount_fuzzy'
|
|
| 'amount_only'
|
|
| 'aggregate_exact'
|
|
| 'already_booked'
|
|
|
|
interface DuplicateCandidate {
|
|
id: string
|
|
date: string
|
|
amount: number
|
|
description: string | null
|
|
merchant_name: string | null
|
|
reference: string | null
|
|
/** already_booked: the verifikat the row is already booked on. */
|
|
journal_entry_id?: string | null
|
|
match_reason: DuplicateMatchReason
|
|
match_confidence: number
|
|
/** aggregate_exact: the other open invoices the bank row also covers. */
|
|
aggregate_invoice_numbers?: string[]
|
|
}
|
|
|
|
interface PaymentBookingDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
invoice: InvoiceWithRelations
|
|
onSuccess: () => void
|
|
}
|
|
|
|
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }
|
|
|
|
export default function PaymentBookingDialog({
|
|
open,
|
|
onOpenChange,
|
|
invoice,
|
|
onSuccess,
|
|
}: PaymentBookingDialogProps) {
|
|
const { toast } = useToast()
|
|
const router = useRouter()
|
|
const { company } = useCompany()
|
|
const t = useTranslations('invoice_payment_dialog')
|
|
|
|
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
|
|
ocr_exact: t('match_reason_ocr_exact'),
|
|
name_amount_fuzzy: t('match_reason_name_amount_fuzzy'),
|
|
amount_only: t('match_reason_amount_only'),
|
|
aggregate_exact: t('match_reason_aggregate_exact'),
|
|
already_booked: t('match_reason_already_booked'),
|
|
}
|
|
|
|
// Session-cached reference data (lib/reference-data), seeded by the
|
|
// dashboard layout: the chart and the settings are known on the first
|
|
// paint, so the proposed lines and the voucher preview resolve as soon as
|
|
// the dialog opens instead of after two sequential requests.
|
|
const { accounts, isLoading: accountsLoading, error: accountsError } = useAccounts()
|
|
const {
|
|
settings: companySettings,
|
|
isLoading: settingsLoading,
|
|
error: settingsError,
|
|
} = useCompanySettings()
|
|
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
|
const [lines, setLines] = useState<FormLine[]>([])
|
|
const accountNameByNumber = useMemo(() => {
|
|
const names = new Map(catalog.map((account) => [account.account_number, account.account_name]))
|
|
for (const account of accounts) names.set(account.account_number, account.account_name)
|
|
return names
|
|
}, [accounts, catalog])
|
|
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [isInitialized, setIsInitialized] = useState(false)
|
|
const [duplicateCandidates, setDuplicateCandidates] = useState<DuplicateCandidate[] | null>(null)
|
|
const [tab, setTab] = useState<'new' | 'existing'>('new')
|
|
// Drives the "Befintlig verifikation" picker copy: cash links against a 19xx
|
|
// debit, accrual against a 1510 credit.
|
|
const accountingMethod: 'accrual' | 'cash' =
|
|
companySettings?.accounting_method === 'cash' ? 'cash' : 'accrual'
|
|
// The bank account the invoice asked to be paid to (1930 when none was
|
|
// chosen): the proposed debit lands there, same as the route's default.
|
|
const { cashAccounts, isLoading: cashAccountsLoading } = useCashAccounts()
|
|
const chosenPaymentAccount = useMemo(() => {
|
|
const id = (invoice as { payment_cash_account_id?: string | null }).payment_cash_account_id
|
|
return id ? cashAccounts.find((a) => a.id === id)?.ledger_account ?? undefined : undefined
|
|
}, [cashAccounts, invoice])
|
|
// source_type the booking will use: drives the voucher-series preview so the
|
|
// number shown matches what mark-paid will actually create.
|
|
const [sourceType, setSourceType] =
|
|
useState<'invoice_cash_payment' | 'invoice_paid' | null>(null)
|
|
const [nextVoucher, setNextVoucher] = useState<{ series: string; next: number | null } | null>(null)
|
|
|
|
// Load accounts and settings when dialog opens
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setIsInitialized(false)
|
|
setDuplicateCandidates(null)
|
|
setTab('new')
|
|
setSourceType(null)
|
|
setNextVoucher(null)
|
|
return
|
|
}
|
|
|
|
// Reference data still loading (no seed, first mount of the session):
|
|
// the effect re-runs once it lands.
|
|
if (accountsLoading || settingsLoading || cashAccountsLoading) return
|
|
|
|
let cancelled = false
|
|
|
|
async function init() {
|
|
try {
|
|
if (accountsError) throw new Error(t('load_chart_failed'))
|
|
if (!company?.id) throw new Error(t('no_active_company'))
|
|
if (settingsError) throw new Error(t('load_settings_failed'))
|
|
|
|
const fetchedCatalog = await loadBasCatalog()
|
|
if (cancelled) return
|
|
|
|
setCatalog(fetchedCatalog)
|
|
|
|
const settings = companySettings
|
|
// /api/settings used to fall back to the company row's entity type
|
|
// when company_settings.entity_type is null; the cached row does not.
|
|
const entityType: EntityType =
|
|
(settings?.entity_type as EntityType | null | undefined) ??
|
|
company.entity_type ??
|
|
'enskild_firma'
|
|
|
|
setSourceType(
|
|
resolveInvoicePaymentSourceType({
|
|
invoiceAlreadyBooked: !!invoice.journal_entry_id,
|
|
accountingMethod,
|
|
}),
|
|
)
|
|
|
|
const proposed = proposePaymentLines({
|
|
invoice: {
|
|
invoice_number: invoice.invoice_number,
|
|
total: invoice.total,
|
|
total_sek: invoice.total_sek,
|
|
subtotal: invoice.subtotal,
|
|
subtotal_sek: invoice.subtotal_sek,
|
|
vat_amount: invoice.vat_amount,
|
|
vat_amount_sek: invoice.vat_amount_sek,
|
|
currency: invoice.currency,
|
|
exchange_rate: invoice.exchange_rate,
|
|
vat_treatment: invoice.vat_treatment,
|
|
items: invoice.items,
|
|
default_dimensions: invoice.default_dimensions,
|
|
ore_rounding: invoice.ore_rounding,
|
|
deduction_total: invoice.deduction_total,
|
|
// #1717: lets the proposal clear the actual remaining on a
|
|
// partially_paid invoice (öre write-off when < 1 kr remains).
|
|
paid_amount: invoice.paid_amount,
|
|
remaining_amount: invoice.remaining_amount,
|
|
},
|
|
accountingMethod,
|
|
entityType,
|
|
paymentAccount: chosenPaymentAccount,
|
|
companyOreRounding:
|
|
typeof settings?.ore_rounding === 'boolean' ? settings.ore_rounding : undefined,
|
|
})
|
|
|
|
setLines(proposed)
|
|
setPaymentDate(new Date().toISOString().split('T')[0])
|
|
setIsInitialized(true)
|
|
} catch (err) {
|
|
if (cancelled) return
|
|
toast({
|
|
title: t('load_dialog_failed_title'),
|
|
description: err instanceof Error ? getErrorMessage(err) : t('try_again'),
|
|
variant: 'destructive',
|
|
})
|
|
onOpenChange(false)
|
|
}
|
|
}
|
|
|
|
init()
|
|
return () => { cancelled = true }
|
|
// companySettings and accountingMethod are read at init time on purpose: a
|
|
// background revalidation of the settings row must not re-run init()
|
|
// (and reset the user's lines) mid-dialog.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, invoice.id, company?.id, accountsLoading, settingsLoading, cashAccountsLoading, chosenPaymentAccount, accountsError, settingsError])
|
|
|
|
// Voucher-series preview: resolve the upcoming serie + nummer the same way the
|
|
// booking engine will, so a misconfigured series is visible before confirming.
|
|
// Re-runs when the payment date changes (vouchers are numbered per period).
|
|
useEffect(() => {
|
|
if (!open || !sourceType) return
|
|
let cancelled = false
|
|
const qs = new URLSearchParams({ source_type: sourceType, date: paymentDate })
|
|
fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`)
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((json) => {
|
|
if (cancelled || !json?.data) return
|
|
setNextVoucher({ series: json.data.series, next: json.data.next })
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setNextVoucher(null)
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [open, sourceType, paymentDate])
|
|
|
|
// Balance computation
|
|
const { totalDebit, totalCredit, isBalanced } = useMemo(() => {
|
|
let totalDebit = 0
|
|
let totalCredit = 0
|
|
for (const line of lines) {
|
|
totalDebit += parseFloat(line.debit_amount) || 0
|
|
totalCredit += parseFloat(line.credit_amount) || 0
|
|
}
|
|
const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0
|
|
return { totalDebit, totalCredit, isBalanced }
|
|
}, [lines])
|
|
|
|
const updateLine = (index: number, field: keyof FormLine, value: string) => {
|
|
setLines((prev) => {
|
|
const next = [...prev]
|
|
const updated = { ...next[index], [field]: value }
|
|
|
|
// Debit/credit exclusion: clear the other when one is entered
|
|
if (field === 'debit_amount' && value) {
|
|
updated.credit_amount = ''
|
|
} else if (field === 'credit_amount' && value) {
|
|
updated.debit_amount = ''
|
|
}
|
|
|
|
next[index] = updated
|
|
return next
|
|
})
|
|
}
|
|
|
|
const addLine = () => {
|
|
setLines((prev) => [...prev, { ...BLANK_LINE }])
|
|
}
|
|
|
|
const removeLine = (index: number) => {
|
|
if (lines.length <= 2) return
|
|
setLines((prev) => prev.filter((_, i) => i !== index))
|
|
}
|
|
|
|
const submit = async (force: boolean) => {
|
|
if (!isBalanced) return
|
|
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
const apiLines = lines
|
|
.filter((l) => l.account_number && (parseFloat(l.debit_amount) || parseFloat(l.credit_amount)))
|
|
.map((l) => ({
|
|
account_number: l.account_number,
|
|
debit_amount: parseFloat(l.debit_amount) || 0,
|
|
credit_amount: parseFloat(l.credit_amount) || 0,
|
|
line_description: l.line_description || undefined,
|
|
// Dimensions PR7: the proposal re-propagates the invoice default;
|
|
// whatever the grid holds is what gets booked.
|
|
dimensions:
|
|
l.dimensions && Object.keys(l.dimensions).length > 0
|
|
? l.dimensions
|
|
: undefined,
|
|
}))
|
|
|
|
const response = await fetch(`/api/invoices/${invoice.id}/mark-paid`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
payment_date: paymentDate,
|
|
lines: apiLines,
|
|
...(force ? { force: true } : {}),
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json()
|
|
const code = (data as { error?: { code?: string } })?.error?.code
|
|
if (code === 'INVOICE_PAID_LIKELY_DUPLICATE') {
|
|
const details = (data as { error?: { details?: { candidates?: DuplicateCandidate[] } } })
|
|
?.error?.details
|
|
setDuplicateCandidates(details?.candidates ?? [])
|
|
setIsSubmitting(false)
|
|
return
|
|
}
|
|
const error = new Error(t('mark_paid_failed')) as Error & { body?: unknown; status?: number }
|
|
error.body = data
|
|
error.status = response.status
|
|
throw error
|
|
}
|
|
|
|
onOpenChange(false)
|
|
onSuccess()
|
|
} catch (error) {
|
|
const anyErr = error as { body?: unknown; status?: number }
|
|
toast({
|
|
title: t('booking_failed_title'),
|
|
description: getErrorMessage(anyErr.body ?? error, { context: 'invoice', statusCode: anyErr.status }),
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
|
|
setIsSubmitting(false)
|
|
}
|
|
|
|
const handleSubmit = () => submit(false)
|
|
const handleForceSubmit = () => submit(true)
|
|
|
|
const handleLinkExisting = (transactionId: string) => {
|
|
onOpenChange(false)
|
|
router.push(`/transactions?highlight=${encodeURIComponent(transactionId)}`)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[680px]">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{/* data-ph-mask: the invoice number is user data */}
|
|
{t('title')}{invoice.invoice_number ? (
|
|
<span data-ph-mask="">{t('title_suffix', { number: invoice.invoice_number })}</span>
|
|
) : ''}
|
|
{nextVoucher && (
|
|
<span className="ml-1 text-muted-foreground tabular-nums">
|
|
({nextVoucher.series}{nextVoucher.next})
|
|
</span>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{formatCurrency(invoice.total, invoice.currency)}
|
|
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
|
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{duplicateCandidates && duplicateCandidates.length > 0 ? (
|
|
<div className="space-y-4">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">{t('duplicate_title')}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{duplicateCandidates.length === 1
|
|
? t('duplicate_one')
|
|
: t('duplicate_many', { count: duplicateCandidates.length })}
|
|
</p>
|
|
</div>
|
|
<ul className="space-y-2">
|
|
{duplicateCandidates.map((c) => {
|
|
const reasonVariant: 'success' | 'secondary' | 'outline' | 'warning' =
|
|
c.match_reason === 'already_booked'
|
|
? 'warning'
|
|
: c.match_reason === 'ocr_exact' || c.match_reason === 'aggregate_exact'
|
|
? 'success'
|
|
: c.match_reason === 'name_amount_fuzzy'
|
|
? 'secondary'
|
|
: 'outline'
|
|
const isAggregate =
|
|
c.match_reason === 'aggregate_exact' && (c.aggregate_invoice_numbers?.length ?? 0) > 0
|
|
// Already a verifikat: linking would book the money twice, so
|
|
// the action is to open that voucher and correct, not to link.
|
|
const isAlreadyBooked = c.match_reason === 'already_booked' && !!c.journal_entry_id
|
|
return (
|
|
<li
|
|
key={c.id}
|
|
className="flex flex-col gap-2 rounded-lg border bg-card p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant={reasonVariant}>{MATCH_REASON_LABEL[c.match_reason]}</Badge>
|
|
<span className="text-sm tabular-nums text-muted-foreground">
|
|
{formatDate(c.date)}
|
|
</span>
|
|
<span className="text-sm font-medium tabular-nums">
|
|
{formatCurrency(c.amount, invoice.currency)}
|
|
</span>
|
|
</div>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{c.merchant_name || c.description || '-'}
|
|
</p>
|
|
{/* A Bankgirot aggregate: the row also settles other
|
|
invoices, so the remedy is the split under
|
|
Transaktioner (one samlingsverifikation, row linked),
|
|
never marking the invoices paid one by one. */}
|
|
{isAggregate && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('aggregate_covers', {
|
|
count: c.aggregate_invoice_numbers!.length,
|
|
numbers: c.aggregate_invoice_numbers!.join(', '),
|
|
})}
|
|
</p>
|
|
)}
|
|
{isAlreadyBooked && (
|
|
<p className="text-xs text-muted-foreground">{t('already_booked_hint')}</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
isAlreadyBooked
|
|
? router.push(`/bookkeeping/${c.journal_entry_id}`)
|
|
: handleLinkExisting(c.id)
|
|
}
|
|
className="shrink-0"
|
|
>
|
|
{isAlreadyBooked
|
|
? t('show_voucher')
|
|
: isAggregate
|
|
? t('allocate_transaction')
|
|
: t('link_transaction')}
|
|
</Button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
) : (
|
|
<Tabs value={tab} onValueChange={(v) => setTab(v as 'new' | 'existing')}>
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="new">{t('tab_new_payment')}</TabsTrigger>
|
|
<TabsTrigger value="existing">{t('tab_existing_voucher')}</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="existing" className="mt-4">
|
|
<LinkVoucherPicker
|
|
invoiceId={invoice.id}
|
|
invoiceCurrency={invoice.currency}
|
|
accountingMethod={accountingMethod}
|
|
onLinked={() => {
|
|
onOpenChange(false)
|
|
onSuccess()
|
|
}}
|
|
onCancel={() => setTab('new')}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="new" className="mt-4">
|
|
{!isInitialized ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Payment date */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
|
|
<Input
|
|
id="payment-date"
|
|
type="date"
|
|
value={paymentDate}
|
|
onChange={(e) => setPaymentDate(e.target.value)}
|
|
className="w-full sm:w-48"
|
|
/>
|
|
</div>
|
|
|
|
{/* Journal entry lines */}
|
|
{/* Mobile card layout */}
|
|
<div className="sm:hidden space-y-3">
|
|
{lines.map((line, index) => (
|
|
<div key={index} className="rounded-lg border bg-card p-3 space-y-2">
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex-1">
|
|
<AccountCombobox
|
|
value={line.account_number}
|
|
accounts={accounts}
|
|
onChange={(val) => updateLine(index, 'account_number', val)}
|
|
selectedName={accountNameByNumber.get(line.account_number)}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px] shrink-0 -mr-1 -mt-1"
|
|
onClick={() => removeLine(index)}
|
|
disabled={lines.length <= 2}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">{t('debit_label')}</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="0,00"
|
|
value={line.debit_amount}
|
|
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
|
className="tabular-nums text-right"
|
|
inputMode="decimal"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">{t('credit_label')}</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="0,00"
|
|
value={line.credit_amount}
|
|
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
|
className="tabular-nums text-right"
|
|
inputMode="decimal"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button type="button" variant="outline" size="sm" onClick={addLine} className="w-full">
|
|
<Plus className="mr-1 h-3.5 w-3.5" /> {t('add_row')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Desktop table layout */}
|
|
<div className="hidden sm:block space-y-2">
|
|
{/* Header */}
|
|
<div className="grid grid-cols-[1fr_120px_120px_32px] gap-2 text-xs font-medium text-muted-foreground px-1">
|
|
<span>{t('account_label')}</span>
|
|
<span className="text-right">{t('debit_label')}</span>
|
|
<span className="text-right">{t('credit_label')}</span>
|
|
<span />
|
|
</div>
|
|
|
|
{/* Lines */}
|
|
{lines.map((line, index) => (
|
|
<div key={index} className="grid grid-cols-[1fr_120px_120px_32px] gap-2 items-start">
|
|
<div className="min-w-0">
|
|
<AccountCombobox
|
|
value={line.account_number}
|
|
accounts={accounts}
|
|
onChange={(val) => updateLine(index, 'account_number', val)}
|
|
selectedName={accountNameByNumber.get(line.account_number)}
|
|
/>
|
|
</div>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="0,00"
|
|
value={line.debit_amount}
|
|
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
|
className="tabular-nums text-right"
|
|
/>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="0,00"
|
|
value={line.credit_amount}
|
|
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
|
className="tabular-nums text-right"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeLine(index)}
|
|
disabled={lines.length <= 2}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
{/* Add row */}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={addLine}
|
|
className="text-muted-foreground"
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
{t('add_row')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Balance indicator */}
|
|
<div className="flex items-center justify-between border-t pt-3">
|
|
<div className="flex items-center gap-2">
|
|
{isBalanced ? (
|
|
<Badge variant="success">
|
|
{t('balanced_badge')}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="destructive">
|
|
{t('unbalanced_badge', { delta: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground tabular-nums">
|
|
{formatCurrency(totalDebit)} / {formatCurrency(totalCredit)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
|
|
{(duplicateCandidates && duplicateCandidates.length > 0) || tab === 'new' ? (
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting} className="w-full sm:w-auto min-h-11">
|
|
{t('cancel')}
|
|
</Button>
|
|
{duplicateCandidates && duplicateCandidates.length > 0 ? (
|
|
<Button
|
|
onClick={handleForceSubmit}
|
|
disabled={!isBalanced || isSubmitting}
|
|
className="w-full sm:w-auto min-h-11"
|
|
>
|
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('book_anyway')}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!isBalanced || isSubmitting || !isInitialized}
|
|
className="w-full sm:w-auto min-h-11"
|
|
>
|
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('confirm_and_book')}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|