26e29f47bc
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1107 lines
47 KiB
TypeScript
1107 lines
47 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, Search, Check, BookmarkPlus } from 'lucide-react'
|
|
import { cn, formatCurrency } from '@/lib/utils'
|
|
import { roundOre } from '@/lib/money'
|
|
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
|
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
|
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
|
|
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 { useAccounts, useCashAccounts, useFiscalPeriods } from '@/lib/reference-data/hooks'
|
|
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 { BookingTemplateLibrary, CashAccount, 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',
|
|
ideell_forening: 'Ideell förening',
|
|
}
|
|
|
|
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)
|
|
|
|
// Session-cached reference data (lib/reference-data), seeded by the
|
|
// dashboard layout: the settlement account, the period and the account
|
|
// picker are known on the first paint instead of after three round trips
|
|
// per open. cashAccounts stays null only while the list is still loading
|
|
// (no seed): the prefill effect below reads that as "not resolved yet".
|
|
const { cashAccounts: cachedCashAccounts, isLoading: cashAccountsLoading } = useCashAccounts()
|
|
const cashAccounts: CashAccount[] | null = cashAccountsLoading ? null : cachedCashAccounts
|
|
const { periods } = useFiscalPeriods()
|
|
const { accounts } = useAccounts()
|
|
// Full BAS catalogue (static reference data, fetched once per session). Lets
|
|
// the account picker surface standard accounts the company hasn't activated
|
|
// yet; picking one activates it at commit via the existing
|
|
// ActivateAccountsDialog rail. Without it the picker only knows the active
|
|
// chart, which reads as "the account doesn't exist".
|
|
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
|
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])
|
|
|
|
// 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])
|
|
|
|
// Load the static BAS catalogue on first open (periods and accounts come
|
|
// from the session cache above).
|
|
useEffect(() => {
|
|
if (!open) return
|
|
let cancelled = false
|
|
loadBasCatalog().then((data) => {
|
|
if (!cancelled) setCatalog(data)
|
|
}).catch(() => {/* search degrades to the active chart */})
|
|
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))
|
|
}, [])
|
|
|
|
// Outstanding imbalance from every line except `excludeIndex`.
|
|
// Positive => debit side is short (a debit on the target row balances it);
|
|
// negative => credit side is short. Same semantics as JournalEntryForm.
|
|
const computeBalancingDiff = useCallback(
|
|
(excludeIndex: number) => {
|
|
const others = lines.filter((_, i) => i !== excludeIndex)
|
|
const d = others.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
|
const c = others.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
|
return roundOre(c - d)
|
|
},
|
|
[lines]
|
|
)
|
|
|
|
// Opt-in balancing (ported from JournalEntryForm): double-click a debit or
|
|
// credit field to fill the amount that makes the entry balance. No-op if
|
|
// already balanced or if the balancing entry belongs on the other side.
|
|
const handleFillBalance = useCallback(
|
|
(idx: number, side: 'debit' | 'credit') => {
|
|
const diff = computeBalancingDiff(idx)
|
|
const fill = side === 'debit' ? diff : -diff
|
|
if (fill <= 0) return
|
|
updateLine(
|
|
idx,
|
|
side === 'debit'
|
|
? { debit_amount: fill.toFixed(2), credit_amount: '' }
|
|
: { credit_amount: fill.toFixed(2), debit_amount: '' }
|
|
)
|
|
},
|
|
[computeBalancingDiff, updateLine]
|
|
)
|
|
|
|
// Replace the line set with a booking template's computed rows. The picker
|
|
// hands back JournalEntryForm-shaped lines; we keep only the three fields
|
|
// book-direct posts. A meaningful supplier description is preserved: the
|
|
// 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-lg 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}
|
|
catalog={catalog}
|
|
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 })}
|
|
onDoubleClick={() => handleFillBalance(idx, 'debit')}
|
|
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 })}
|
|
onDoubleClick={() => handleFillBalance(idx, 'credit')}
|
|
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">
|
|
<div className="flex items-center justify-between gap-3">
|
|
{/* The remaining debit/credit gap, right where the user
|
|
reconciles the sums: what is still missing to balance. */}
|
|
{totals.diff !== 0 ? (
|
|
<span className="tabular-nums font-medium text-destructive">
|
|
Differens {Math.abs(totals.diff).toFixed(2)}
|
|
</span>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<span className="text-right font-medium uppercase tracking-wider text-muted-foreground">
|
|
Summa
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td
|
|
className={cn(
|
|
'px-3 py-2 text-right tabular-nums font-medium',
|
|
totals.diff !== 0 && 'text-destructive'
|
|
)}
|
|
>
|
|
{totals.debit.toFixed(2)}
|
|
</td>
|
|
<td
|
|
className={cn(
|
|
'px-3 py-2 text-right tabular-nums font-medium',
|
|
totals.diff !== 0 && 'text-destructive'
|
|
)}
|
|
>
|
|
{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>
|
|
) : totals.diff !== 0 ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
Dubbelklicka i ett tomt beloppsfält för att fylla i differensen
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<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-attn' : '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>
|
|
)
|
|
}
|