c0a106e591
* feat(booking): batch VAT seeds from category default, period derives from entry date BatchCategorySelector and BulkBookInboxDialog hardcoded standard_25 as the initial VAT treatment, overriding the server's per-category derivation and claiming 25% moms on VAT-exempt bank fees. Both now default to an explicit 'Enligt kategori' option that omits vat_treatment so the server derives it (exempt bank/card fees, 12% representation). Reverse charge is never derived. The embedded JournalEntryForm period Select is replaced by the same derived read-only text the standalone variant already uses: the period is a total function of the entry date, and the Select allowed picking a period that disagreed with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(booking): prefill cost account from counterparty history; period text in Bokfor direkt BookDirectlyDialog and the supplier-invoice form left the cost account deliberately blank even when the company's own confirmed history for the counterparty (categorization_templates) or supplier.default_expense_account knew the answer. Both now prefill from a counterparty-template hit (new ?counterparty= single-match mode on the settings route, same tiered matcher as the booking flows), only into still-empty fields, only from expense-shaped templates, with a provenance line. No generic fallback: a miss leaves the field blank exactly as before. Bokfor direkt's period Select is replaced by text derived from the entry date; the silent periods[0] fallback becomes a blocking explanation, since borrowing an arbitrary period could book into the wrong one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ux): single-company login skips the picker; filing surfaces default to filable periods /select-company auto-forwards when the user is a member of exactly one company with nothing else to decide (no new TIC engagements, no pending invite, enrichment fresh); the in-app 'Lagg till foretag' links pass ?choose=1 to keep the picker deliberately reachable. Byra/multi-company users are untouched. The VAT declaration now opens on the most recently ENDED month/quarter (lib/vat/period-defaults, tested) instead of the current one, which can never be filed and forced a step-back click on every filing visit; the periodicity switch resets the same way. Helarsmoms FyPicker gains preferLatestEnded and opens on the latest ended rakenskapsar instead of the newest started one. The 'momsperiod saknas' dead end now collects the answer inline through the same PUT /api/settings validation instead of bouncing to settings: until the period exists the deadline engine generates zero VAT deadlines, silently, so every extra hop kept a compliance hole open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(granskning): approve pill commits directly for low and medium risk The Godkann pill on /pending only opened a ConfirmationDialog demanding a second Godkann, regardless of tier. The review row already states source, title and risk and offers Detaljer, so for low/medium the pill now commits directly; high risk keeps the dialog, whose warning sentence carries information the row does not. Chat-side bulk approve is deferred: it needs ApprovalCard's state lifted (assistant-redesign seam 8.8), see DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reports): map inline momsperiod save errors through getErrorMessage Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): repair the dead login auto-forward and nine review findings The big one: setActiveCompany ends with a cookie write that throws during Server Component render (sealed cookie store), so the /select-company auto-forward silently never fired; the write is now best-effort since the cookie is write-only compat and the DB write is already verified. Also: supplier-switch un-plants history-prefilled accounts so the new supplier's own default applies; prefill routes through handleAccountChange so konto default moms rides along; batch 'Ingen moms' books exempt instead of the derived 25%; monthly VAT default tracks the actual 12th/17th filing deadline (over-40M stays M-1); inline momsperiod setup uses EmptyState, gates on vat_number (the PUT would 400 without it), keeps keyboard focus and announces errors; cost-account shape guard tightened to P&L accounts; attn tone on the new warning lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger workflows; the Actions outage swallowed the rebase push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after outage (events dropped, not delayed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger after GitHub Actions recovery Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address CodeRabbit and compliance-bot findings Direct commit now prunes the op from the bulk selection (a stale id kept inflating the bulk bar and rode into bulk-commit) and the detail-panel Godkann gets the same risk gate as the row pill. The automatic account fill in the supplier-invoice form is requested, not applied inline: the applying effect waits for both the BAS chart and the request with fresh closures, so a fill can no longer land before the chart and leave a VAT-free konto on the 25% row default. Test dates use local-time constructors (ISO strings parse as UTC midnight and shift a day in negative-offset timezones). Stale ML 11 kap citation dropped from a comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger; push event dropped again Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1071 lines
45 KiB
TypeScript
1071 lines
45 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check, BookmarkPlus } from 'lucide-react'
|
|
import { cn, formatCurrency } from '@/lib/utils'
|
|
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
|
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
|
|
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
|
|
import { TemplateForm } from '@/components/settings/TemplateForm'
|
|
import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library'
|
|
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import {
|
|
useSubmitWithAccountActivation,
|
|
throwOnStructuredError,
|
|
} from '@/lib/hooks/use-submit-with-account-activation'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
|
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
|
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
|
|
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
|
import { AttnLine } from '@/components/ui/attn-line'
|
|
import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InboxChannelContext, InvoiceExtractionResult } from '@/types'
|
|
|
|
interface InboxItem {
|
|
id: string
|
|
document_id: string | null
|
|
matched_transaction_id: string | null
|
|
extracted_data: InvoiceExtractionResult | null
|
|
// Verified human answers from the delivering chat (WhatsApp items):
|
|
// prefills the notes field so representation deltagare + syfte reach the
|
|
// verifikat. Absent for email/upload items.
|
|
channel_context?: InboxChannelContext | null
|
|
}
|
|
|
|
interface PickerTransaction {
|
|
id: string
|
|
date: string
|
|
description: string
|
|
amount: number
|
|
currency: string | null
|
|
amount_sek?: number | null
|
|
exchange_rate?: number | null
|
|
}
|
|
|
|
// SEK magnitude of a (usually-SEK) bank transaction. Foreign rows are
|
|
// normalised via their stored amount_sek/exchange_rate so ranking against the
|
|
// underlag's SEK value is apples-to-apples.
|
|
function txSekAmount(tx: PickerTransaction): number {
|
|
const cur = (tx.currency ?? 'SEK').toUpperCase()
|
|
if (cur === 'SEK') return Math.abs(tx.amount)
|
|
return Math.abs(
|
|
resolveSekAmount(tx.amount, tx.amount_sek ?? null, tx.currency, tx.exchange_rate ?? null),
|
|
)
|
|
}
|
|
|
|
interface FormLine {
|
|
account_number: string
|
|
debit_amount: string
|
|
credit_amount: string
|
|
}
|
|
|
|
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '' }
|
|
|
|
// Swedish entity labels for the "Spara som mall" editor. Hard-coded to match
|
|
// this dialog's Swedish-only surface (the shared TemplateForm handles the rest
|
|
// of its own strings bilingually).
|
|
const TEMPLATE_ENTITY_LABELS: Record<string, string> = {
|
|
all: 'Alla',
|
|
enskild_firma: 'Enskild firma',
|
|
aktiebolag: 'Aktiebolag',
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (v: boolean) => void
|
|
item: InboxItem
|
|
/** Signed URL + mime of the inbox document, threaded from the workspace so
|
|
the underlag can be shown beside the form without an extra round-trip. */
|
|
docUrl?: string | null
|
|
docMime?: string | null
|
|
onSuccess: () => void | Promise<void>
|
|
}
|
|
|
|
// Compute the prefill lines. Booking is always in SEK (BFL/BFNAR), so when
|
|
// a transaction is selected and the document is in a foreign currency, the
|
|
// transaction's SEK amount is the canonical figure. The cost-account row
|
|
// stays blank: the user must pick a cost account themselves.
|
|
// bankAccount defaults to '1930' but is replaced by the resolved ledger account
|
|
// once the cash-accounts fetch completes.
|
|
function buildPrefillLines(
|
|
item: InboxItem,
|
|
selectedTransactionAmount: number | null = null,
|
|
bankAccount: string = '1930',
|
|
): FormLine[] {
|
|
const docTotal = item.extracted_data?.totals?.total ?? null
|
|
const docVat = item.extracted_data?.totals?.vatAmount ?? null
|
|
const docCurrency = item.extracted_data?.invoice?.currency ?? 'SEK'
|
|
|
|
// Prefer the transaction amount when available: it's already in SEK and
|
|
// matches the bank movement we'll be marking as booked.
|
|
const total = selectedTransactionAmount != null
|
|
? Math.abs(selectedTransactionAmount)
|
|
: docTotal
|
|
|
|
if (total == null || total <= 0) {
|
|
return [{ ...BLANK_LINE }, { ...BLANK_LINE }]
|
|
}
|
|
|
|
const totalRounded = Math.round(total * 100) / 100
|
|
|
|
// VAT prefill rules:
|
|
// - Foreign-currency document → skip VAT (reverse charge is the common
|
|
// case; user can add it manually if needed).
|
|
// - SEK-denominated document with extracted VAT → split it out on 2641.
|
|
// - SEK without extracted VAT → leave VAT row out, single net row.
|
|
const useDocVat =
|
|
docCurrency === 'SEK' &&
|
|
selectedTransactionAmount == null &&
|
|
docVat != null &&
|
|
docVat > 0
|
|
const vatRounded = useDocVat ? Math.round((docVat ?? 0) * 100) / 100 : 0
|
|
const net = Math.round((totalRounded - vatRounded) * 100) / 100
|
|
|
|
const lines: FormLine[] = [
|
|
{
|
|
account_number: '',
|
|
debit_amount: String(net),
|
|
credit_amount: '',
|
|
},
|
|
]
|
|
if (vatRounded > 0) {
|
|
lines.push({
|
|
account_number: '2641',
|
|
debit_amount: String(vatRounded),
|
|
credit_amount: '',
|
|
})
|
|
}
|
|
lines.push({
|
|
account_number: bankAccount,
|
|
debit_amount: '',
|
|
credit_amount: String(totalRounded),
|
|
})
|
|
return lines
|
|
}
|
|
|
|
// Rank candidates by closeness to the underlag's SEK value. `targetSek` is the
|
|
// document total already converted to SEK (the bank charge for a 216 USD
|
|
// receipt is ~2 109 kr, not 216): ranking against the raw foreign total used
|
|
// to bury the real match far down the list. Null target → leave order intact.
|
|
function rankBySekCloseness(
|
|
rows: PickerTransaction[],
|
|
targetSek: number | null
|
|
): PickerTransaction[] {
|
|
if (targetSek == null) return rows
|
|
const abs = Math.abs(targetSek)
|
|
return [...rows].sort((a, b) => Math.abs(txSekAmount(a) - abs) - Math.abs(txSekAmount(b) - abs))
|
|
}
|
|
|
|
export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = null, docMime = null, onSuccess }: Props) {
|
|
const { toast } = useToast()
|
|
const { company } = useCompany()
|
|
|
|
// Underlag total + currency. Booking happens in SEK, so a foreign total needs
|
|
// an FX rate to rank/compare against the (SEK) bank transactions.
|
|
const targetAmount = item.extracted_data?.totals?.total ?? null
|
|
const targetCurrency = (item.extracted_data?.invoice?.currency ?? 'SEK').toUpperCase()
|
|
// SEK per unit of the underlag currency (e.g. ~9.8 for USD). null = SEK,
|
|
// pending, or unsupported.
|
|
const [fxRate, setFxRate] = useState<number | null>(null)
|
|
|
|
// null = fetch pending; array = loaded (may be empty on error: falls back to '1930')
|
|
const [cashAccounts, setCashAccounts] = useState<CashAccount[] | null>(null)
|
|
|
|
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
|
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
|
const [entryDate, setEntryDate] = useState<string>(
|
|
item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10)
|
|
)
|
|
const [periodId, setPeriodId] = useState<string>('')
|
|
const [description, setDescription] = useState<string>(() => {
|
|
const supplier = item.extracted_data?.supplier?.name?.trim() || ''
|
|
const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || ''
|
|
return [supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg'
|
|
})
|
|
const [notes, setNotes] = useState<string>('')
|
|
// Start with blank lines; they are replaced once cashAccounts resolves (see
|
|
// the combined prefill effect below). This mirrors the TransactionBookingDialog
|
|
// pattern of gating JournalEntryForm on bankAccount !== null.
|
|
const [lines, setLines] = useState<FormLine[]>(() => buildPrefillLines(item))
|
|
|
|
// Transaction picker: optional selection.
|
|
const [selectedTransactionId, setSelectedTransactionId] = useState<string | null>(
|
|
item.matched_transaction_id
|
|
)
|
|
const [transactions, setTransactions] = useState<PickerTransaction[]>([])
|
|
const [isLoadingTransactions, setIsLoadingTransactions] = useState(false)
|
|
const [txSearch, setTxSearch] = useState('')
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
// "Spara som mall" — derive amount-parameterised template lines from the
|
|
// current konteringsrader so the user can save the pattern they just worked
|
|
// out. Labels come from the loaded BAS chart; the user reviews/edits in the
|
|
// shared TemplateForm before saving.
|
|
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
|
|
|
|
// Reset state when a different item opens the dialog. We pass bankAccount
|
|
// here but it may still be null (fetch in flight): in that case '1930' is
|
|
// used as a placeholder and the prefill-update effect below will overwrite
|
|
// the settlement line once the fetch resolves.
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setEntryDate(item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10))
|
|
setLines(buildPrefillLines(item, null, bankAccount ?? '1930'))
|
|
setSelectedTransactionId(item.matched_transaction_id)
|
|
const supplier = item.extracted_data?.supplier?.name?.trim() || ''
|
|
const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || ''
|
|
setDescription([supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg')
|
|
// WhatsApp items: prefill with the rendered chat context (representation
|
|
// deltagare + syfte, sender note) so it lands on the verifikat unless the
|
|
// user edits it away. This is the one place the photo caption is included:
|
|
// the user reads it here and can change or delete it before booking, which
|
|
// no other path offers (see channel-context-notes.ts).
|
|
//
|
|
// The dialog always submits the field, empty string included, so clearing
|
|
// the prefill really clears it: the server only defaults when the field is
|
|
// absent from the request.
|
|
setNotes(renderChannelContextNotes(item.channel_context, { includeCaption: true }) ?? '')
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, item.id])
|
|
|
|
// Cost-account prefill from the company's own booking history for this
|
|
// supplier (counterparty templates). Fills only the first line's still-empty
|
|
// account: never a generic seed (the old silent-'5010' incident is the
|
|
// reason there is no fallback), never over anything the user typed, and only
|
|
// for expense-shaped templates (cost on debit, settlement on credit) so an
|
|
// income template can't plant a revenue account on a purchase.
|
|
const [accountSuggestion, setAccountSuggestion] = useState<{ account: string; counterparty: string } | null>(null)
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setAccountSuggestion(null)
|
|
const supplier = item.extracted_data?.supplier?.name?.trim()
|
|
if (!supplier) return
|
|
let cancelled = false
|
|
;(async () => {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/settings/counterparty-templates?counterparty=${encodeURIComponent(supplier)}`
|
|
)
|
|
if (!res.ok) return
|
|
const json = await res.json()
|
|
if (cancelled) return
|
|
const match = json?.data
|
|
const debit: string | undefined = match?.template?.debit_account
|
|
const credit: string | undefined = match?.template?.credit_account
|
|
if (!match || (match.confidence ?? 0) < 0.5) return
|
|
// P&L cost on debit (4xxx-8xxx), settlement on credit: keeps private
|
|
// and balance-sheet templates (2013, 1630, 12xx) out of a cost field.
|
|
if (!debit || !/^[4-8]/.test(debit) || !credit || !credit.startsWith('19')) return
|
|
setLines((current) => {
|
|
if (!current[0] || current[0].account_number) return current
|
|
return current.map((l, i) => (i === 0 ? { ...l, account_number: debit } : l))
|
|
})
|
|
setAccountSuggestion({ account: debit, counterparty: match.template.counterparty_name })
|
|
} catch {
|
|
// Prefill is best-effort; the field simply stays blank.
|
|
}
|
|
})()
|
|
return () => { cancelled = true }
|
|
}, [open, item.id, item.extracted_data?.supplier?.name])
|
|
|
|
// Fetch the underlag's SEK rate for a foreign-currency document so candidate
|
|
// transactions can be ranked against the SEK-equivalent total (and not the
|
|
// raw foreign number). SEK / unsupported currencies skip the fetch.
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setFxRate(null)
|
|
if (targetCurrency === 'SEK' || !['EUR', 'USD', 'GBP', 'NOK', 'DKK'].includes(targetCurrency)) {
|
|
return
|
|
}
|
|
let cancelled = false
|
|
const invoiceDate = item.extracted_data?.invoice?.invoiceDate
|
|
const dateParam = invoiceDate ? `&date=${invoiceDate}` : ''
|
|
fetch(`/api/currency/rate?currency=${targetCurrency}${dateParam}`)
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((body) => {
|
|
if (cancelled) return
|
|
const rate = body?.data?.rate
|
|
if (typeof rate === 'number' && rate > 0) setFxRate(rate)
|
|
})
|
|
.catch(() => { /* leave null: ranking falls back to face amounts */ })
|
|
return () => { cancelled = true }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, targetCurrency, item.id])
|
|
|
|
// Fetch cash accounts once when the dialog opens so the settlement line can
|
|
// be routed to the correct ledger account instead of the hardcoded '1930'.
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setCashAccounts(null)
|
|
let cancelled = false
|
|
fetch('/api/cash-accounts')
|
|
.then((r) => {
|
|
if (!r.ok) throw new Error(`cash-accounts fetch failed: ${r.status}`)
|
|
return r.json()
|
|
})
|
|
.then((json) => {
|
|
if (cancelled) return
|
|
setCashAccounts((json.data ?? []) as CashAccount[])
|
|
})
|
|
.catch(() => {
|
|
// Fall back to empty list: resolveAccount will return '1930'
|
|
if (!cancelled) setCashAccounts([])
|
|
})
|
|
return () => { cancelled = true }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, item.id])
|
|
|
|
// SEK-equivalent of the underlag total: the anchor for ranking candidates.
|
|
const targetSek = useMemo(() => {
|
|
if (targetAmount == null) return null
|
|
if (targetCurrency === 'SEK') return targetAmount
|
|
if (fxRate != null) return Math.round(targetAmount * fxRate * 100) / 100
|
|
return null
|
|
}, [targetAmount, targetCurrency, fxRate])
|
|
|
|
// When the user picks a transaction (or the toggle changes), re-derive
|
|
// the prefilled amounts so foreign-currency invoices follow the SEK
|
|
// figure on the actual bank movement. Normalised to SEK: a foreign bank
|
|
// row is booked at its SEK value, never its face amount.
|
|
const selectedTransactionAmount = useMemo(() => {
|
|
if (!selectedTransactionId) return null
|
|
const tx = transactions.find((t) => t.id === selectedTransactionId)
|
|
if (!tx) return null
|
|
const cur = (tx.currency ?? 'SEK').toUpperCase()
|
|
return cur === 'SEK'
|
|
? tx.amount
|
|
: resolveSekAmount(tx.amount, tx.amount_sek ?? null, tx.currency, tx.exchange_rate ?? null)
|
|
}, [selectedTransactionId, transactions])
|
|
|
|
// The settlement currency to resolve against:
|
|
// - When a transaction is selected, use that transaction's currency.
|
|
// - Otherwise, use the document's currency (falls back to SEK).
|
|
const settlementCurrency = useMemo(() => {
|
|
if (selectedTransactionId) {
|
|
const tx = transactions.find((t) => t.id === selectedTransactionId)
|
|
if (tx) return (tx.currency ?? 'SEK').toUpperCase()
|
|
}
|
|
return targetCurrency
|
|
}, [selectedTransactionId, transactions, targetCurrency])
|
|
|
|
// Resolved bank account: null while the cash-accounts fetch is in flight.
|
|
// Derived from the cash accounts list; falls back to '1930' if the list is
|
|
// empty or no single-currency match exists.
|
|
const bankAccount = useMemo<string | null>(() => {
|
|
if (cashAccounts === null) return null
|
|
const { account } = resolveAccount(cashAccounts, null, settlementCurrency)
|
|
return account
|
|
}, [cashAccounts, settlementCurrency])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
// Update amounts when the transaction selection or resolved bank account
|
|
// changes, but preserve user-entered account numbers. This handles "user
|
|
// typed cost account, then picked an SEK-denominated transaction": we
|
|
// want the SEK figure to flow into the line amounts without forgetting
|
|
// their account pick. bankAccount may be null while the fetch is in flight;
|
|
// pass '1930' as a safe placeholder in that case: the effect re-runs once
|
|
// the fetch resolves and bankAccount becomes non-null.
|
|
setLines((current) => {
|
|
const next = buildPrefillLines(item, selectedTransactionAmount, bankAccount ?? '1930')
|
|
return next.map((nl, i) => {
|
|
const existing = current[i]
|
|
if (!existing) return nl
|
|
return {
|
|
...nl,
|
|
account_number: existing.account_number || nl.account_number,
|
|
}
|
|
})
|
|
})
|
|
}, [open, item, selectedTransactionAmount, bankAccount])
|
|
|
|
// Fetch fiscal periods and accounts on first open
|
|
useEffect(() => {
|
|
if (!open) return
|
|
let cancelled = false
|
|
;(async () => {
|
|
try {
|
|
const [periodsRes, accountsRes] = await Promise.all([
|
|
fetch('/api/bookkeeping/fiscal-periods'),
|
|
fetch('/api/bookkeeping/accounts'),
|
|
])
|
|
const periodsJson = await periodsRes.json()
|
|
const accountsJson = await accountsRes.json()
|
|
if (cancelled) return
|
|
setPeriods(periodsJson.data || [])
|
|
setAccounts(accountsJson.data || [])
|
|
} catch (err) {
|
|
console.error('[book-direct] fetch reference data failed:', err)
|
|
}
|
|
})()
|
|
return () => { cancelled = true }
|
|
}, [open])
|
|
|
|
// Derive the fiscal period from the entry date. Periods never overlap, so
|
|
// this is a total function of the date; when the date falls outside every
|
|
// period the id clears and submit is blocked with an explanation. The old
|
|
// else-branch silently borrowed periods[0], which could book into the wrong
|
|
// period with only the DB period trigger left to catch it.
|
|
useEffect(() => {
|
|
if (periods.length === 0) return
|
|
const match = periods.find(
|
|
(p) => entryDate >= p.period_start && entryDate <= p.period_end
|
|
)
|
|
setPeriodId(match ? match.id : '')
|
|
}, [entryDate, periods])
|
|
|
|
// Fetch unmatched transactions whenever the dialog opens: the picker
|
|
// is always visible now (selection is optional).
|
|
useEffect(() => {
|
|
if (!open) return
|
|
let cancelled = false
|
|
setIsLoadingTransactions(true)
|
|
;(async () => {
|
|
try {
|
|
const res = await fetch('/api/transactions?unmatched=true')
|
|
const json = await res.json()
|
|
if (cancelled) return
|
|
const rows: PickerTransaction[] = (Array.isArray(json.data) ? json.data : [])
|
|
.map((t: PickerTransaction) => ({
|
|
id: t.id,
|
|
date: t.date,
|
|
description: t.description,
|
|
amount: t.amount,
|
|
currency: t.currency || 'SEK',
|
|
amount_sek: t.amount_sek ?? null,
|
|
exchange_rate: t.exchange_rate ?? null,
|
|
}))
|
|
// Ranking happens in a memo (it depends on the async FX rate).
|
|
setTransactions(rows)
|
|
} catch (err) {
|
|
console.error('[book-direct] fetch transactions failed:', err)
|
|
} finally {
|
|
if (!cancelled) setIsLoadingTransactions(false)
|
|
}
|
|
})()
|
|
return () => { cancelled = true }
|
|
}, [open])
|
|
|
|
// FX-aware ranking by closeness to the underlag's SEK value.
|
|
const rankedTransactions = useMemo(
|
|
() => rankBySekCloseness(transactions, targetSek),
|
|
[transactions, targetSek],
|
|
)
|
|
|
|
const filteredTransactions = useMemo(() => {
|
|
const term = txSearch.trim().toLowerCase()
|
|
if (!term) return rankedTransactions
|
|
return rankedTransactions.filter((t) => (t.description || '').toLowerCase().includes(term))
|
|
}, [rankedTransactions, txSearch])
|
|
|
|
// Pin the already-selected/matched transaction to the top so it's always
|
|
// visible: otherwise a correct match that ranks past the rendered cap looks
|
|
// unselected and the user re-picks it. The pinned row carries a "Matchad"
|
|
// badge when it's the one matched in the inbox.
|
|
const displayedTransactions = useMemo(() => {
|
|
if (!selectedTransactionId) return filteredTransactions
|
|
const sel = filteredTransactions.find((t) => t.id === selectedTransactionId)
|
|
if (!sel) return filteredTransactions
|
|
return [sel, ...filteredTransactions.filter((t) => t.id !== selectedTransactionId)]
|
|
}, [filteredTransactions, selectedTransactionId])
|
|
|
|
const totals = useMemo(() => {
|
|
const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
|
const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
|
const roundedDebit = Math.round(debit * 100) / 100
|
|
const roundedCredit = Math.round(credit * 100) / 100
|
|
return {
|
|
debit: roundedDebit,
|
|
credit: roundedCredit,
|
|
balanced: roundedDebit === roundedCredit && roundedDebit > 0,
|
|
diff: Math.round((roundedDebit - roundedCredit) * 100) / 100,
|
|
}
|
|
}, [lines])
|
|
|
|
// Account number → BAS name, so derived template lines get meaningful labels.
|
|
const accountNameMap = useMemo(
|
|
() => Object.fromEntries(accounts.map((a) => [a.account_number, a.account_name])),
|
|
[accounts],
|
|
)
|
|
|
|
// Template lines derived from the current booking. Empty (<2 usable lines)
|
|
// disables the "Spara som mall" button.
|
|
const derivedTemplateLines = useMemo(
|
|
() => deriveTemplateLinesFromBooking(lines, accountNameMap),
|
|
[lines, accountNameMap],
|
|
)
|
|
|
|
const updateLine = useCallback((idx: number, patch: Partial<FormLine>) => {
|
|
setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)))
|
|
}, [])
|
|
|
|
const addLine = useCallback(() => {
|
|
setLines((prev) => [...prev, { ...BLANK_LINE }])
|
|
}, [])
|
|
|
|
const removeLine = useCallback((idx: number) => {
|
|
setLines((prev) => prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx))
|
|
}, [])
|
|
|
|
// Replace the line set with a booking template's computed rows. The picker
|
|
// hands back JournalEntryForm-shaped lines; we keep only the three fields
|
|
// book-direct posts. A meaningful supplier description is preserved: the
|
|
// template name only fills an empty field.
|
|
const handleTemplateApply = useCallback(
|
|
(
|
|
templateLines: Array<{ account_number: string; debit_amount: string; credit_amount: string }>,
|
|
templateDescription: string,
|
|
) => {
|
|
setLines(
|
|
templateLines.map((l) => ({
|
|
account_number: l.account_number,
|
|
debit_amount: l.debit_amount,
|
|
credit_amount: l.credit_amount,
|
|
})),
|
|
)
|
|
setDescription((prev) => (prev.trim() ? prev : templateDescription))
|
|
},
|
|
[],
|
|
)
|
|
|
|
const derivedPeriod = useMemo(
|
|
() => periods.find((p) => p.id === periodId) ?? null,
|
|
[periods, periodId],
|
|
)
|
|
const derivedPeriodBlocked = !!(derivedPeriod?.locked_at || derivedPeriod?.is_closed)
|
|
|
|
const disabledReason = useMemo(() => {
|
|
if (isSubmitting) return null
|
|
if (!entryDate) return 'Välj datum'
|
|
if (!periodId) return 'Datumet matchar ingen öppen räkenskapsperiod'
|
|
if (derivedPeriodBlocked) return 'Räkenskapsperioden är låst eller stängd'
|
|
if (description.trim().length === 0) return 'Fyll i beskrivning'
|
|
if (lines.some((l) => l.account_number.trim().length === 0)) return 'Alla rader behöver ett konto'
|
|
if (!totals.balanced) return 'Debet och kredit måste vara lika'
|
|
return null
|
|
}, [isSubmitting, entryDate, periodId, derivedPeriodBlocked, description, lines, totals.balanced])
|
|
|
|
const canSubmit = !isSubmitting && disabledReason === null
|
|
|
|
const postBooking = useCallback(async () => {
|
|
const payload = {
|
|
fiscal_period_id: periodId,
|
|
entry_date: entryDate,
|
|
description: description.trim(),
|
|
// Always send the field, '' included: the server treats an absent
|
|
// `notes` as "default it from the chat context" and a present one as
|
|
// the user's own value. Sending undefined for a cleared prefill would
|
|
// resurrect the text the user just deleted onto an immutable verifikat.
|
|
notes: notes.trim(),
|
|
lines: lines.map((l) => ({
|
|
account_number: l.account_number.trim(),
|
|
debit_amount: parseFloat(l.debit_amount) || 0,
|
|
credit_amount: parseFloat(l.credit_amount) || 0,
|
|
})),
|
|
transaction_id: selectedTransactionId ?? undefined,
|
|
}
|
|
const res = await fetch(
|
|
`/api/extensions/ext/invoice-inbox/items/${item.id}/book-direct`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}
|
|
)
|
|
return (await throwOnStructuredError(res)) as {
|
|
data?: { journal_entry?: { voucher_series: string; voucher_number: number } }
|
|
}
|
|
}, [periodId, entryDate, description, notes, lines, selectedTransactionId, item.id])
|
|
|
|
const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } =
|
|
useSubmitWithAccountActivation(postBooking)
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!canSubmit) return
|
|
setIsSubmitting(true)
|
|
try {
|
|
const json = await runSubmit()
|
|
const voucher = json?.data?.journal_entry
|
|
toast({
|
|
title: 'Bokfört',
|
|
description: voucher
|
|
? `Verifikation ${formatVoucher(voucher)} skapad.`
|
|
: 'Verifikation skapad.',
|
|
})
|
|
await onSuccess()
|
|
onOpenChange(false)
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message === 'cancelled') {
|
|
// User dismissed the activation dialog: no toast needed
|
|
} else {
|
|
const anyErr = err as { body?: unknown; status?: number }
|
|
toast({
|
|
title: 'Kunde inte bokföra',
|
|
description: getErrorMessage(anyErr.body ?? err, {
|
|
context: 'journal_entry',
|
|
statusCode: anyErr.status,
|
|
}),
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}, [canSubmit, runSubmit, toast, onSuccess, onOpenChange])
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Bokför direkt</DialogTitle>
|
|
<DialogDescription>
|
|
Skapa en verifikation från underlaget. Dokumentet bifogas verifikationen som underlag.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)]">
|
|
{/* Document column: sticky on desktop so the underlag stays visible
|
|
while the form scrolls; stacks above the form on smaller screens. */}
|
|
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
|
|
<DocumentViewerPane
|
|
documentId={item.document_id}
|
|
mime={docMime}
|
|
downloadUrl={docUrl}
|
|
className="h-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* Booking form */}
|
|
<div className="space-y-6 pt-2">
|
|
{/* Metadata row */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="bd-date">Datum</Label>
|
|
<Input
|
|
id="bd-date"
|
|
type="date"
|
|
value={entryDate}
|
|
onChange={(e) => setEntryDate(e.target.value)}
|
|
disabled={isSubmitting}
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label>Räkenskapsperiod</Label>
|
|
{/* Derived from the entry date (periods never overlap): text,
|
|
not a picker, so it can never disagree with the date. */}
|
|
{periods.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground pt-2">Hämtar perioder …</p>
|
|
) : derivedPeriod ? (
|
|
<p className="text-sm pt-2 tabular-nums">
|
|
{derivedPeriod.period_start}: {derivedPeriod.period_end}
|
|
{(derivedPeriod.locked_at || derivedPeriod.is_closed) && (
|
|
<span className="text-attn">
|
|
{' '}({derivedPeriod.locked_at ? 'låst' : 'stängd'})
|
|
</span>
|
|
)}
|
|
</p>
|
|
) : (
|
|
<AttnLine className="pt-2">
|
|
Datumet ligger utanför öppna räkenskapsperioder. Ändra datumet eller skapa perioden under Bokföring.
|
|
</AttnLine>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="bd-description">Beskrivning</Label>
|
|
<Input
|
|
id="bd-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
disabled={isSubmitting}
|
|
placeholder="Leverantör · fakturanummer"
|
|
/>
|
|
</div>
|
|
|
|
{/* Transaction picker: always shown, selection is optional. */}
|
|
<div className="rounded-lg border p-4 space-y-3">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-sm">Koppla till banktransaktion (valfritt)</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Välj en transaktion om dokumentet motsvarar en redan-bokad
|
|
bankhändelse: den bokas då samtidigt. Lämna tom för en
|
|
fristående verifikation.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Sök på beskrivning…"
|
|
value={txSearch}
|
|
onChange={(e) => setTxSearch(e.target.value)}
|
|
className="pl-10"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="max-h-56 overflow-y-auto rounded-md border">
|
|
{isLoadingTransactions ? (
|
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Laddar…
|
|
</div>
|
|
) : filteredTransactions.length === 0 ? (
|
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
Inga okategoriserade transaktioner.
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y">
|
|
{displayedTransactions.slice(0, 30).map((tx) => {
|
|
const isSelected = selectedTransactionId === tx.id
|
|
const isInboxMatch = item.matched_transaction_id === tx.id
|
|
const cur = (tx.currency || 'SEK').toUpperCase()
|
|
const sek = txSekAmount(tx)
|
|
return (
|
|
<li key={tx.id}>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'w-full flex items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors',
|
|
isSelected
|
|
? 'bg-primary/10 border-l-2 border-primary'
|
|
: 'border-l-2 border-transparent hover:bg-accent/40'
|
|
)}
|
|
onClick={() =>
|
|
setSelectedTransactionId(isSelected ? null : tx.id)
|
|
}
|
|
disabled={isSubmitting}
|
|
>
|
|
<span className="shrink-0 w-4 flex items-center justify-center">
|
|
{isSelected ? (
|
|
<Check className="h-3.5 w-3.5 text-primary" />
|
|
) : null}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<p className="truncate">{tx.description}</p>
|
|
{isInboxMatch && (
|
|
<Badge variant="secondary" className="shrink-0 text-[10px] px-1.5 py-0">
|
|
Matchad
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground tabular-nums">{tx.date}</p>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<span
|
|
className={cn(
|
|
'tabular-nums text-sm block',
|
|
tx.amount < 0 ? 'text-destructive' : 'text-foreground'
|
|
)}
|
|
>
|
|
{formatCurrency(tx.amount, tx.currency || 'SEK')}
|
|
</span>
|
|
{cur !== 'SEK' && (
|
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
|
≈ {formatCurrency(sek, 'SEK')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
{selectedTransactionId && (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground hover:text-foreground underline"
|
|
onClick={() => setSelectedTransactionId(null)}
|
|
disabled={isSubmitting}
|
|
>
|
|
Rensa val
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Journal entry lines */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<Label className="text-sm">Konteringsrader</Label>
|
|
<div className="text-xs text-muted-foreground text-right">
|
|
{targetAmount != null && (
|
|
<span>
|
|
Underlag:{' '}
|
|
<span className="tabular-nums font-medium text-foreground">
|
|
{formatCurrency(targetAmount, targetCurrency)}
|
|
</span>
|
|
</span>
|
|
)}
|
|
{selectedTransactionAmount != null && (
|
|
<span>
|
|
{targetAmount != null && ' · '}
|
|
Transaktion:{' '}
|
|
<span className="tabular-nums font-medium text-foreground">
|
|
{formatCurrency(Math.abs(selectedTransactionAmount), 'SEK')}
|
|
</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{targetCurrency !== 'SEK' && selectedTransactionAmount != null && (
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Underlaget är i {targetCurrency}. Bokföringen sker i SEK enligt
|
|
transaktionens belopp. Momsraden har lämnats bort: vid behov
|
|
lägg till en rad för omvänd skattskyldighet manuellt.
|
|
</p>
|
|
)}
|
|
{accountSuggestion && lines[0]?.account_number === accountSuggestion.account && (
|
|
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
<span aria-hidden className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
|
|
Konto {accountSuggestion.account} föreslaget från tidigare bokföringar av{' '}
|
|
{formatCounterpartyName(accountSuggestion.counterparty)}
|
|
</p>
|
|
)}
|
|
<div className="rounded-lg border overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/40">
|
|
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
|
<th className="text-left font-medium px-3 py-2 w-[40%]">Konto</th>
|
|
<th className="text-right font-medium px-3 py-2">Debet</th>
|
|
<th className="text-right font-medium px-3 py-2">Kredit</th>
|
|
<th className="w-10" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{lines.map((line, idx) => (
|
|
<tr key={idx}>
|
|
<td className="px-3 py-2">
|
|
<AccountCombobox
|
|
value={line.account_number}
|
|
accounts={accounts}
|
|
onChange={(v) => updateLine(idx, { account_number: v })}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
inputMode="decimal"
|
|
value={line.debit_amount}
|
|
onChange={(e) => updateLine(idx, { debit_amount: e.target.value, credit_amount: e.target.value ? '' : line.credit_amount })}
|
|
disabled={isSubmitting}
|
|
className="text-right tabular-nums"
|
|
placeholder="0,00"
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
inputMode="decimal"
|
|
value={line.credit_amount}
|
|
onChange={(e) => updateLine(idx, { credit_amount: e.target.value, debit_amount: e.target.value ? '' : line.debit_amount })}
|
|
disabled={isSubmitting}
|
|
className="text-right tabular-nums"
|
|
placeholder="0,00"
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-2 text-right">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => removeLine(idx)}
|
|
disabled={isSubmitting || lines.length <= 2}
|
|
aria-label="Ta bort rad"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot className="bg-muted/20 text-xs">
|
|
<tr>
|
|
<td className="px-3 py-2 text-right font-medium uppercase tracking-wider text-muted-foreground">
|
|
Summa
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums font-medium">
|
|
{totals.debit.toFixed(2)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums font-medium">
|
|
{totals.credit.toFixed(2)}
|
|
</td>
|
|
<td />
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={addLine}
|
|
disabled={isSubmitting}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
Lägg till rad
|
|
</Button>
|
|
<BookingTemplatePicker
|
|
onApply={handleTemplateApply}
|
|
entityType={company?.entity_type}
|
|
defaultAmount={
|
|
selectedTransactionAmount != null
|
|
? Math.abs(selectedTransactionAmount)
|
|
: targetSek ?? undefined
|
|
}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowSaveTemplate(true)}
|
|
disabled={isSubmitting || derivedTemplateLines.length < 2}
|
|
title={
|
|
derivedTemplateLines.length < 2
|
|
? 'Fyll i minst två konteringsrader med konto och belopp'
|
|
: undefined
|
|
}
|
|
>
|
|
<BookmarkPlus className="h-3.5 w-3.5 mr-1.5" />
|
|
Spara som mall
|
|
</Button>
|
|
</div>
|
|
{totals.balanced ? (
|
|
<Badge variant="success" className="text-[11px]">
|
|
Balanserad
|
|
</Badge>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground flex items-center gap-1.5 tabular-nums">
|
|
<AlertTriangle className="h-3.5 w-3.5 text-warning" />
|
|
Diff {totals.diff.toFixed(2)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="bd-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
Anteckningar (valfritt)
|
|
</Label>
|
|
<Textarea
|
|
id="bd-notes"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
disabled={isSubmitting}
|
|
rows={2}
|
|
placeholder="Intern kommentar om verifikationen"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-3 pt-2 border-t">
|
|
<p
|
|
className={cn(
|
|
'text-xs tabular-nums',
|
|
disabledReason ? 'text-warning-foreground' : 'text-muted-foreground'
|
|
)}
|
|
aria-live="polite"
|
|
>
|
|
{disabledReason ?? 'Klar att bokföra.'}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
Avbryt
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={!canSubmit}
|
|
title={disabledReason ?? undefined}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
Bokför…
|
|
</>
|
|
) : (
|
|
'Bokför'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
<ActivateAccountsDialog
|
|
open={activationDialog.open}
|
|
accountNumbers={activationDialog.accountNumbers}
|
|
onConfirm={confirmActivation}
|
|
onCancel={cancelActivation}
|
|
/>
|
|
|
|
{/* Save the current kontering as a reusable template. Amounts are stored
|
|
as ratios of the total, so the user picks a fresh amount when applying
|
|
the mall later. The shared TemplateForm re-seeds from the derived lines
|
|
each time the dialog opens (Radix unmounts its content when closed). */}
|
|
<Dialog open={showSaveTemplate} onOpenChange={setShowSaveTemplate}>
|
|
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Spara som bokföringsmall</DialogTitle>
|
|
<DialogDescription>
|
|
Spara den här konteringen som en återanvändbar mall. Beloppen sparas
|
|
som andelar av totalsumman — du anger ett nytt belopp när du använder
|
|
mallen. Kontrollera raderna nedan innan du sparar.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{showSaveTemplate && (
|
|
<TemplateForm
|
|
mode="create"
|
|
entityLabels={TEMPLATE_ENTITY_LABELS}
|
|
initialTemplate={{
|
|
id: '',
|
|
company_id: null,
|
|
team_id: null,
|
|
created_by: null,
|
|
name: description.trim(),
|
|
description: '',
|
|
category: 'other',
|
|
entity_type: company?.entity_type ?? 'all',
|
|
lines: derivedTemplateLines,
|
|
is_system: false,
|
|
is_active: true,
|
|
created_at: '',
|
|
updated_at: '',
|
|
} satisfies BookingTemplateLibrary}
|
|
onSaved={() => setShowSaveTemplate(false)}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Dialog>
|
|
)
|
|
}
|