diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index de522dc5..7dcdac9b 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -326,6 +326,9 @@ export default function TransactionsPage() { const [bookingDialogOpen, setBookingDialogOpen] = useState(false) const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) const [bookingDialogTemplate, setBookingDialogTemplate] = useState(null) + // Account picked from the template picker's "Konton" search results: + // prefills the counter line when the manual booking dialog opens. + const [bookingDialogAccount, setBookingDialogAccount] = useState(null) // Attach-underlag dialog (tx→doc mirror of the Documents view's matcher) const [attachDocTx, setAttachDocTx] = useState(null) @@ -3389,10 +3392,23 @@ export default function TransactionsPage() { if (templatePickerTransaction) { setBookingDialogTransaction(templatePickerTransaction) setBookingDialogTemplate(null) + setBookingDialogAccount(null) setBookingDialogOpen(true) } } + // Account picked from the template picker's "Konton" search group + // (issue #1877): same route as "Bokför manuellt", with the picked account + // prefilled on the counter line of the journal entry form. + function handlePickAccount(accountNumber: string) { + if (!templatePickerTransaction) return + setBookingDialogTransaction(templatePickerTransaction) + setBookingDialogTemplate(null) + setBookingDialogAccount(accountNumber) + setTemplatePickerOpen(false) + setBookingDialogOpen(true) + } + // Complex (multi-leg or otherwise non-convertible) library template picked // from the transaction modal: route into the manual booking dialog with // the template pre-applied against the transaction's amount. @@ -3400,6 +3416,7 @@ export default function TransactionsPage() { if (!templatePickerTransaction) return setBookingDialogTransaction(templatePickerTransaction) setBookingDialogTemplate(raw) + setBookingDialogAccount(null) setTemplatePickerOpen(false) setBookingDialogOpen(true) } @@ -3964,10 +3981,14 @@ export default function TransactionsPage() { open onOpenChange={(o) => { setBookingDialogOpen(o) - if (!o) setBookingDialogTemplate(null) + if (!o) { + setBookingDialogTemplate(null) + setBookingDialogAccount(null) + } }} transaction={bookingDialogTransaction} preselectedTemplate={bookingDialogTemplate} + preselectedAccount={bookingDialogAccount} onBooked={handleTransactionBooked} /> )} @@ -4037,6 +4058,7 @@ export default function TransactionsPage() { handleOpenTemplateReview(templatePickerTransaction, templateId) }} onPickLibraryTemplate={handlePickLibraryTemplate} + onSelectAccount={handlePickAccount} /> } diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index da642ee6..6344c137 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -13,6 +13,12 @@ import { type BookingTemplate, type TemplateGroup, } from '@/lib/bookkeeping/booking-templates' +import { + buildActiveAccountIndex, + searchAccounts, + type AccountSearchItem, + type ChartAccountLike, +} from '@/lib/bookkeeping/account-search' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' import { convertLibraryToBookingTemplate, LIBRARY_TEMPLATE_PREFIX, isLibraryTemplateId } from '@/lib/bookkeeping/template-library' @@ -20,6 +26,10 @@ import { getAccountName } from '@/lib/bookkeeping/client-account-names' import type { BookingTemplateLibrary, EntityType } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' +// Cap on the "Konton" search-result group: enough to cover sibling accounts +// on a number-prefix query without drowning the template results. +const ACCOUNT_RESULT_LIMIT = 8 + const GROUP_ORDER: TemplateGroup[] = [ 'premises', 'vehicle', 'it_software', 'office_supplies', 'marketing', 'travel', 'representation', 'insurance', 'professional_services', @@ -199,6 +209,32 @@ function TemplateCard({ template, selected, onClick, compact }: TemplateCardProp ) } +// A chart-of-accounts hit in the search results (issue #1877): clicking it +// routes into the manual booking flow with the account prefilled, so typing +// "5460" or "Förbrukningsmaterial" always yields a path to booking even when +// no template covers the account. +function AccountResultCard({ account, onClick }: { account: AccountSearchItem; onClick: () => void }) { + const t = useTranslations('tx_template_picker') + return ( + + ) +} + interface TemplatePickerProps { direction: 'expense' | 'income' entityType?: EntityType @@ -207,6 +243,11 @@ interface TemplatePickerProps { onSelect: (template: BookingTemplate) => void onSelectCounterparty?: (templateId: string) => void onPickLibraryTemplate?: (raw: BookingTemplateLibrary) => void + // When provided, the search field also matches the company's active chart + // of accounts and shows hits as a "Konton" result group; picking one routes + // into the manual booking flow with the account prefilled. Omitting it + // keeps the picker template-only (and skips the accounts fetch). + onSelectAccount?: (accountNumber: string) => void selectedTemplateId?: string } @@ -217,12 +258,18 @@ export default function TemplatePicker({ onSelect, onSelectCounterparty, onPickLibraryTemplate, + onSelectAccount, selectedTemplateId, }: TemplatePickerProps) { const t = useTranslations('tx_template_picker') const [searchQuery, setSearchQuery] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [libraryRaw, setLibraryRaw] = useState([]) + const [chartAccounts, setChartAccounts] = useState([]) + // Boolean gate rather than the callback itself: the parent recreates the + // handler every render, and depending on its identity would refetch the + // chart on each keystroke of the page underneath. + const accountSearchEnabled = !!onSelectAccount // Map direction to template direction filter (transfers show in both). // Direction filtering applies only to the static "Vanliga mallar" list: // user-created library templates ignore it (inferred direction is unreliable @@ -250,6 +297,29 @@ export default function TemplatePicker({ return () => { controller.abort() } }, []) + // Fetch the company's active chart so the search field can surface real + // accounts (issue #1877: an active konto like 5460 was unfindable because + // only template metadata was searched). The endpoint returns active + // accounts by default; buildActiveAccountIndex re-filters as defense in + // depth. Gated on the consumer actually routing account picks somewhere. + useEffect(() => { + if (!accountSearchEnabled) return + const controller = new AbortController() + ;(async () => { + try { + const res = await fetch('/api/bookkeeping/accounts', { signal: controller.signal }) + if (!res.ok) return + const { data } = await res.json() as { data?: ChartAccountLike[] } + if (data) setChartAccounts(data) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + } + })() + return () => { controller.abort() } + }, [accountSearchEnabled]) + + const accountIndex = useMemo(() => buildActiveAccountIndex(chartAccounts), [chartAccounts]) + // Lazy convertibility map. A template is "convertible" if it fits the // simple debit/credit pair shape the QuickReview booking path expects. // Non-convertible templates are still shown: they just route to the @@ -310,23 +380,35 @@ export default function TemplatePicker({ }) }, [relevantLibraryRaw, convertedById]) - // Search results (static + library). Library search ignores direction; the - // static catalog still respects it because it's curated content. + // Search results (static + library + chart accounts). Library search + // ignores direction; the static catalog still respects it because it's + // curated content. Accounts are direction-agnostic: the manual flow the + // pick routes into handles either side. const searchResults = useMemo< - | { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[] } + | { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[]; accounts: AccountSearchItem[] } | null >(() => { - if (!searchQuery.trim()) return null - const q = searchQuery.toLowerCase() + const qTrimmed = searchQuery.trim() + if (!qTrimmed) return null + const q = qTrimmed.toLowerCase() + const isDigits = /^\d+$/.test(qTrimmed) const libraryMatches = sortedLibraryRaw.filter((tt) => tt.name.toLowerCase().includes(q) || - (tt.description ?? '').toLowerCase().includes(q) + (tt.description ?? '').toLowerCase().includes(q) || + // An all-digit query also prefix-matches the accounts a user template + // books to, mirroring the static catalog's account matching: business + // lines only, so the settlement leg (typically 1930) and VAT lines do + // not light up every template. + (isDigits && tt.lines.some((l) => l.type === 'business' && l.account.startsWith(qTrimmed))) ) const staticMatches = searchTemplates(searchQuery, entityType).filter((tt) => { return tt.direction === templateDirection || tt.direction === 'transfer' }) - return { library: libraryMatches, staticTemplates: staticMatches } - }, [searchQuery, entityType, templateDirection, sortedLibraryRaw]) + const accountMatches = accountSearchEnabled + ? searchAccounts(accountIndex, searchQuery, ACCOUNT_RESULT_LIMIT) + : [] + return { library: libraryMatches, staticTemplates: staticMatches, accounts: accountMatches } + }, [searchQuery, entityType, templateDirection, sortedLibraryRaw, accountIndex, accountSearchEnabled]) // Group templates by group for display const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon]) @@ -396,7 +478,7 @@ export default function TemplatePicker({ setSearchQuery(e.target.value)} - placeholder={t('search_placeholder')} + placeholder={accountSearchEnabled ? t('search_placeholder_with_accounts') : t('search_placeholder')} className="pl-9 h-9" /> @@ -406,31 +488,55 @@ export default function TemplatePicker({ {/* Search results */} {searchResults !== null ? ( (() => { - const totalResults = searchResults.library.length + searchResults.staticTemplates.length + const templateResults = searchResults.library.length + searchResults.staticTemplates.length + const totalResults = templateResults + searchResults.accounts.length return ( -
-

- {totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })} -

-
- {searchResults.library.map((raw) => ( - handleSelectLibraryRaw(raw)} - /> - ))} - {searchResults.staticTemplates.map((tt) => ( - handleSelect(tt)} - /> - ))} +
+
+

+ {totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })} +

+ {totalResults === 0 && accountSearchEnabled && ( +

+ {t('no_results_manual_hint')} +

+ )} +
+ {searchResults.library.map((raw) => ( + handleSelectLibraryRaw(raw)} + /> + ))} + {searchResults.staticTemplates.map((tt) => ( + handleSelect(tt)} + /> + ))} +
+ {searchResults.accounts.length > 0 && onSelectAccount && ( +
+

+ {t('accounts_group')} +

+
+ {searchResults.accounts.map((acc) => ( + onSelectAccount(acc.account_number)} + /> + ))} +
+
+ )}
) })() diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index e43a1f90..f696a88c 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -33,12 +33,17 @@ interface TransactionBookingDialogProps { matched?: boolean, ) => void preselectedTemplate?: BookingTemplateLibrary | null + /** Account number (string, e.g. '5460') to prefill on the counter line: + * set when the user picked an account from the template picker's "Konton" + * search results. Ignored when a preselectedTemplate is present. */ + preselectedAccount?: string | null } function buildInitialLines( transaction: TransactionWithInvoice, bankLineDescription: string, bankAccount: string = '1930', + counterAccount?: string | null, ): FormLine[] { const sekAmount = Math.round(Math.abs(resolveSekAmount( transaction.amount, @@ -67,7 +72,7 @@ function buildInitialLines( } const counterLine: FormLine = { - account_number: '', + account_number: counterAccount ?? '', debit_amount: isExpense ? amountStr : '', credit_amount: isExpense ? '' : amountStr, line_description: '', @@ -113,6 +118,7 @@ export default function TransactionBookingDialog({ transaction, onBooked, preselectedTemplate, + preselectedAccount, }: TransactionBookingDialogProps) { const t = useTranslations('tx_booking_dialog') const { toast } = useToast() @@ -385,12 +391,12 @@ export default function TransactionBookingDialog({
{bankAccount !== null && ( { expect(searchAccounts(idx, '6', 2)).toHaveLength(2) }) }) + +// The booking-dialog picker path (issue #1877): chart rows straight from +// /api/bookkeeping/accounts, indexed active-only, searched by number prefix +// or name. Synthetic fixture mirrors the reported case: 5460 active, plus a +// deactivated sibling that must never surface. +describe('buildActiveAccountIndex', () => { + const chart: ChartAccountLike[] = [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1, is_active: true }, + { account_number: '5460', account_name: 'Förbrukningsmaterial', account_class: 5, is_active: true }, + { account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5, is_active: false }, + // No is_active flag at all: counts as active (server already filtered). + { account_number: '6212', account_name: 'Mobiltelefon', account_class: 6 }, + ] + const activeIdx = buildActiveAccountIndex(chart) + + it('finds an active account by number prefix (the reported "5460" case)', () => { + expect(numbers(searchAccounts(activeIdx, '5460'))).toEqual(['5460']) + expect(numbers(searchAccounts(activeIdx, '54'))).toEqual(['5460']) + }) + + it('finds an active account by name, case-insensitively with åäö', () => { + expect(numbers(searchAccounts(activeIdx, 'Förbrukningsmaterial'))).toEqual(['5460']) + expect(numbers(searchAccounts(activeIdx, 'FÖRBRUKNINGSMATERIAL'))).toEqual(['5460']) + expect(numbers(searchAccounts(activeIdx, 'forbrukningsmaterial'))).toEqual(['5460']) + }) + + it('excludes accounts explicitly marked inactive', () => { + expect(numbers(searchAccounts(activeIdx, '5410'))).toEqual([]) + expect(numbers(searchAccounts(activeIdx, 'Förbrukningsinventarier'))).toEqual([]) + }) + + it('treats rows without an is_active flag as active', () => { + expect(numbers(searchAccounts(activeIdx, 'mobiltelefon'))).toEqual(['6212']) + }) + + it('marks every indexed row as active for the result renderer', () => { + expect(searchAccounts(activeIdx, '').every((i) => i.isActive)).toBe(true) + }) +}) diff --git a/lib/bookkeeping/__tests__/booking-templates.test.ts b/lib/bookkeeping/__tests__/booking-templates.test.ts index dbed5d5c..7a4e2de7 100644 --- a/lib/bookkeeping/__tests__/booking-templates.test.ts +++ b/lib/bookkeeping/__tests__/booking-templates.test.ts @@ -184,6 +184,38 @@ describe('searchTemplates', () => { const results = searchTemplates('annonsering EU') expect(results.some((t) => t.id === 'marketing_online_ads_eu')).toBe(true) }) + + // Account-number matching (issue #1877): an all-digit token prefix-matches + // the template's business account, so typing a konto in the booking + // dialog's search field surfaces the templates that book to it. + it('finds an expense template by its debit (business) account number', () => { + const results = searchTemplates('5010') + expect(results.some((t) => t.id === 'premises_rent')).toBe(true) + }) + + it('finds an income template by its credit (business) account number', () => { + const results = searchTemplates('3001') + expect(results.some((t) => t.id === 'revenue_standard_25')).toBe(true) + }) + + it('matches both legs of a transfer template', () => { + const results = searchTemplates('1630') + expect(results.some((t) => t.id === 'financial_tax_account')).toBe(true) + }) + + it('does not match the settlement leg (1930 must not light up every template)', () => { + const results = searchTemplates('1930') + expect(results.some((t) => t.id === 'premises_rent')).toBe(false) + expect(results.some((t) => t.id === 'revenue_standard_25')).toBe(false) + // Transfers legitimately involve the bank account on a business leg. + expect(results.every((t) => t.direction === 'transfer')).toBe(true) + }) + + it('prefix-matches account numbers (partial konto narrows, text does not match accounts)', () => { + expect(searchTemplates('501').some((t) => t.id === 'premises_rent')).toBe(true) + // A non-digit token never matches via accounts. + expect(searchTemplates('501x').some((t) => t.id === 'premises_rent')).toBe(false) + }) }) // ============================================================ diff --git a/lib/bookkeeping/account-search.ts b/lib/bookkeeping/account-search.ts index ed717321..6cffd21e 100644 --- a/lib/bookkeeping/account-search.ts +++ b/lib/bookkeeping/account-search.ts @@ -28,6 +28,14 @@ export interface SearchableAccount { description?: string | null } +/** + * A chart row as served by /api/bookkeeping/accounts: a SearchableAccount + * that may also carry the chart's is_active flag. + */ +export interface ChartAccountLike extends SearchableAccount { + is_active?: boolean | null +} + /** A single result row the combobox renders. */ export interface AccountSearchItem { account_number: string @@ -91,6 +99,17 @@ export function buildAccountIndex(opts: { return entries } +/** + * Index over the company's ACTIVE chart only. Rows explicitly marked inactive + * are dropped; rows without the flag count as active (the accounts API already + * filters server-side, this keeps the index honest if a caller feeds it an + * unfiltered list). Used by the booking dialog's template picker, where + * deactivated accounts must not surface as bookable search results. + */ +export function buildActiveAccountIndex(rows: ChartAccountLike[]): AccountIndexEntry[] { + return buildAccountIndex({ active: rows.filter((r) => r.is_active !== false) }) +} + /** * Search the index. Returns ranked items (active first), capped at `limit`. * diff --git a/lib/bookkeeping/booking-templates.ts b/lib/bookkeeping/booking-templates.ts index 06455b48..61cedd24 100644 --- a/lib/bookkeeping/booking-templates.ts +++ b/lib/bookkeeping/booking-templates.ts @@ -1649,6 +1649,26 @@ export function getTemplateGroups(): TemplateGroupInfo[] { * Fuzzy search templates by name, keywords, or description. * Optionally filter by entity type. */ +/** + * Account-number matching for template search: an all-digit token prefix- + * matches the template's BUSINESS account(s), i.e. the cost/revenue side, + * not the settlement side. Matching the settlement leg too would make "1930" + * (the default bank account) light up nearly every template, which is noise, + * not search. Transfers have no business/settlement split, so both legs + * match. AB-variant accounts are included so the search works for both + * entity types. Account numbers are identifiers (strings): prefix match only. + */ +function templateAccountMatches(t: BookingTemplate, token: string): boolean { + if (!/^\d+$/.test(token)) return false + const candidates = + t.direction === 'expense' + ? [t.debit_account, t.debit_account_ab] + : t.direction === 'income' + ? [t.credit_account, t.credit_account_ab] + : [t.debit_account, t.credit_account, t.debit_account_ab, t.credit_account_ab] + return candidates.some((acc) => !!acc && acc.startsWith(token)) +} + export function searchTemplates(query: string, entityType?: EntityType): BookingTemplate[] { if (!query.trim()) return [] const q = query.toLowerCase() @@ -1666,7 +1686,8 @@ export function searchTemplates(query: string, entityType?: EntityType): Booking t.name_en.toLowerCase().includes(token) || t.description_sv.toLowerCase().includes(token) || t.keywords.some((kw) => kw.toLowerCase().includes(token)) || - t.id.includes(token) + t.id.includes(token) || + templateAccountMatches(t, token) ) }) } diff --git a/messages/en.json b/messages/en.json index 831426d1..feb32dea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3130,8 +3130,11 @@ "vat_exempt": "VAT-free", "requires_vat_reg": "Requires VAT reg. no.", "search_placeholder": "Search template...", + "search_placeholder_with_accounts": "Search template or account...", "no_results": "No results", + "no_results_manual_hint": "Can't find the account? Use Bokför manuellt to search the full chart of accounts.", "n_results": "{count} results", + "accounts_group": "Accounts", "my_templates": "My templates", "previous_counterparties": "Previous counterparties", "suggested": "Suggested", diff --git a/messages/sv.json b/messages/sv.json index fb0fd225..64243460 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3130,8 +3130,11 @@ "vat_exempt": "Momsfri", "requires_vat_reg": "Kräver momsreg.nr", "search_placeholder": "Sök mall...", + "search_placeholder_with_accounts": "Sök mall eller konto...", "no_results": "Inga resultat", + "no_results_manual_hint": "Hittar du inte kontot? Via Bokför manuellt kan du söka i hela kontoplanen.", "n_results": "{count} resultat", + "accounts_group": "Konton", "my_templates": "Mina mallar", "previous_counterparties": "Tidigare motparter", "suggested": "Föreslagna",