* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects We never sent auth_method to Enable Banking, so it fell back to the ASPSP's visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate* PSUs the redirect flow does not support Mobile BankID, so authorization failed right after the user approved in the BankID app. Mobile BankID at Handelsbanken is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses when requested explicitly. Resolve the bank's preferred auth method before /auth: query the ASPSP's auth_methods and pick the DECOUPLED (Mobile BankID) method when present, otherwise leave auth_method unset so banks that already work are untouched. The method name is read dynamically per psu_type, so it is robust across sandbox/production naming. - api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods field name (was available_auth_methods, never populated), add getPreferredAuthMethod(), thread optional authMethod through startAuthorization - index: resolve authMethod in /connect and pass it on both fresh + reconnect - tests: cover method selection and request-body shaping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(invoice-inbox): clean up bulk-selection toolbar UI Redesign the selection toolbar shown when inbox items are checked: one solid primary "Bokför valda" button with outlined secondary actions ("Fråga assistenten", "Ta bort") and a plain selection count. Removes the redundant "Avmarkera" button (users uncheck the still-visible box), fixes label clipping, and gives the toolbar more breathing room. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(entitlements): bypass paywall in local development Add isPaywallBypassed() so all gated capabilities are testable locally without a subscription. Fires only on NODE_ENV=development (npm run dev) or an explicit DISABLE_PAYWALL=true escape hatch — production builds run under NODE_ENV=production and the entitlement suite runs under 'test', so both keep exercising the real gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): implement categorize core for bank transaction categorization - Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations. - Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing. - Implemented fiscal period validation and duplicate booking detection. - Enhanced logging and error handling for transaction categorization. feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata - Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken. - Outputs metadata for business and personal PSU types, including default authentication methods. fix(migrations): increase statement timeout for SIE bulk delete operations - Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports. feat(migrations): add bulk book inbox items to pending operations - Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`. - Supports bulk booking of matched inbox items against bank transactions. test(pg): add tests for replace_period_opening_balance_link RPC - Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow. - Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries. * fix(sie-export): update journal entries and lines handling in SIE export tests * fix(migrations): resolve version collision on 20260629160000 The SIE bulk-delete statement_timeout migration shared version 20260629160000 with journal_entries_list_series_filter (merged from main via #798/#823), causing a schema_migrations_pkey duplicate key error on apply. Rename the branch's migration to 20260629160100. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compliance): resolve compliance-swarm + review findings - opening-balance/correct: compensating rollback for the non-atomic storno+rebook so a mid-sequence failure never leaves two posted OB entries (ASVS V2.3); durable audit event on every failure path (V16); reference the original verifikationsnummer in the corrected entry per BFL 5 kap 5§; document that requireWrite already enforces write-role + membership (V8.2.1 was a false positive) - reports sources routes: validate the cursor date component as ISO (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2), applied to both the VAT-declaration and trial-balance routes - AgentSessionList: await the rename PATCH, revert the optimistic title and toast on failure (ASVS V4.5) - bank booking: exclude same-batch siblings from the booking-time duplicate guard so bulk-booking distinct same-(date,amount) transactions no longer false-positives; pre-existing duplicate detection is preserved - BulkBookInboxDialog: drop the unsafe currency-based reverse_charge default, add an omvänd skattskyldighet advisory, and type VAT options to the backend VatTreatment union - OpeningBalanceRowEditor: hold onChange in a ref (synced in effect, not during render) so an unstable callback can't cause a render loop Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
|
import { Plus } from 'lucide-react'
|
|
import { Input } from '@/components/ui/input'
|
|
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
|
|
import {
|
|
buildAccountIndex,
|
|
searchAccounts,
|
|
type SearchableAccount,
|
|
type AccountSearchItem,
|
|
} from '@/lib/bookkeeping/account-search'
|
|
import type { BASAccount } from '@/types'
|
|
|
|
interface AccountComboboxProps {
|
|
value: string
|
|
accounts: BASAccount[]
|
|
onChange: (accountNumber: string) => void
|
|
// Fired when the user definitively commits an account: selecting from the
|
|
// dropdown (Enter or click) or typing a full 4-digit number. Distinct from
|
|
// onChange, which also fires on intermediate edits. Callers use this to
|
|
// auto-advance focus (e.g. to the amount field).
|
|
onCommit?: (accountNumber: string) => void
|
|
// When provided, an inline "Skapa nytt konto" affordance appears in the
|
|
// dropdown's empty state. The current search string is passed so the caller
|
|
// can prefill the create dialog.
|
|
onCreateAccount?: (prefill: string) => void
|
|
// The full BAS catalogue. When provided, accounts not yet in `accounts`
|
|
// (the company's active chart) become searchable by name and are surfaced
|
|
// with the `notActivatedLabel` marker; picking one activates it at commit
|
|
// via the existing ACCOUNTS_NOT_IN_CHART rail.
|
|
catalog?: SearchableAccount[]
|
|
// Label shown next to catalogue-only (not-yet-activated) accounts. Defaults
|
|
// to Swedish; bilingual hosts pass a localized string.
|
|
notActivatedLabel?: string
|
|
// Extra classes merged into the trigger Input — callers pass `h-8` for dense
|
|
// table rows, omit it to use the default Input height.
|
|
className?: string
|
|
// Optional callback ref to the underlying <input>, invoked alongside the
|
|
// internal one. Lets a parent imperatively focus the field (e.g. auto-advance
|
|
// to the next konteringsrad's account on Enter — see JournalEntryForm.focusAccount).
|
|
inputRef?: React.RefCallback<HTMLInputElement>
|
|
}
|
|
|
|
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef }: AccountComboboxProps) {
|
|
const [search, setSearch] = useState(value)
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const internalInputRef = useRef<HTMLInputElement>(null)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Attach the internal ref (used for focus bookkeeping) and forward the element
|
|
// to any external callback ref the parent passed.
|
|
const setInputRef = useCallback((el: HTMLInputElement | null) => {
|
|
internalInputRef.current = el
|
|
inputRef?.(el)
|
|
}, [inputRef])
|
|
|
|
// Sync external value changes into the search field
|
|
useEffect(() => {
|
|
setSearch(value)
|
|
}, [value])
|
|
|
|
// Index the active chart + the full BAS catalogue once per source change.
|
|
// Searching it per keystroke is then just substring checks over pre-folded
|
|
// haystacks (number + name + description, diacritics stripped).
|
|
const accountIndex = useMemo(
|
|
() => buildAccountIndex({ active: accounts, catalog }),
|
|
[accounts, catalog]
|
|
)
|
|
|
|
const filteredAccounts = useMemo(
|
|
() => searchAccounts(accountIndex, search),
|
|
[accountIndex, search]
|
|
)
|
|
|
|
// Group filtered accounts by class
|
|
const groupedAccounts = useMemo(() => {
|
|
const groups: { className: string; accounts: AccountSearchItem[] }[] = []
|
|
const groupMap = new Map<string, AccountSearchItem[]>()
|
|
|
|
for (const account of filteredAccounts) {
|
|
const className = getAccountClassName(account.account_class)
|
|
if (!groupMap.has(className)) {
|
|
groupMap.set(className, [])
|
|
}
|
|
groupMap.get(className)!.push(account)
|
|
}
|
|
|
|
for (const [className, accts] of groupMap) {
|
|
groups.push({ className, accounts: accts })
|
|
}
|
|
|
|
return groups
|
|
}, [filteredAccounts])
|
|
|
|
// Flat list for keyboard navigation
|
|
const flatList = useMemo(() => filteredAccounts, [filteredAccounts])
|
|
|
|
// Reset highlight when filtered results change
|
|
useEffect(() => {
|
|
setHighlightedIndex(0)
|
|
}, [filteredAccounts])
|
|
|
|
// Scroll highlighted item into view
|
|
useEffect(() => {
|
|
if (!isOpen || !listRef.current) return
|
|
const highlighted = listRef.current.querySelector('[data-highlighted="true"]')
|
|
if (highlighted) {
|
|
highlighted.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
}, [highlightedIndex, isOpen])
|
|
|
|
// Close dropdown when clicking/tapping outside
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent | TouchEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
document.addEventListener('touchstart', handleClickOutside)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
document.removeEventListener('touchstart', handleClickOutside)
|
|
}
|
|
}, [])
|
|
|
|
const selectAccount = useCallback(
|
|
(accountNumber: string) => {
|
|
onChange(accountNumber)
|
|
setSearch(accountNumber)
|
|
setIsOpen(false)
|
|
onCommit?.(accountNumber)
|
|
},
|
|
[onChange, onCommit]
|
|
)
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!isOpen) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
setIsOpen(true)
|
|
e.preventDefault()
|
|
}
|
|
return
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
setHighlightedIndex((prev) => Math.min(prev + 1, flatList.length - 1))
|
|
break
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
|
break
|
|
case 'Enter':
|
|
e.preventDefault()
|
|
if (flatList[highlightedIndex]) {
|
|
selectAccount(flatList[highlightedIndex].account_number)
|
|
}
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
setIsOpen(false)
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newValue = e.target.value
|
|
setSearch(newValue)
|
|
// Emit any 4-digit numeric value to the parent. Unknown BAS numbers are
|
|
// accepted optimistically — the submit-time ActivateAccountsDialog lets
|
|
// the user activate missing accounts without leaving the form. A complete
|
|
// 4-digit number is treated as a commit so focus can advance to the amount.
|
|
if (/^\d{4}$/.test(newValue)) {
|
|
onChange(newValue)
|
|
// Only treat as a commit when the value newly becomes this account, so
|
|
// editing an already-committed number doesn't keep stealing focus. On
|
|
// commit, close the dropdown too — focus advances to the amount field, so
|
|
// a lingering open list would just cover the rows below.
|
|
if (newValue !== value) {
|
|
onCommit?.(newValue)
|
|
setIsOpen(false)
|
|
return
|
|
}
|
|
}
|
|
if (!isOpen) {
|
|
setIsOpen(true)
|
|
}
|
|
}
|
|
|
|
const handleFocus = () => {
|
|
setIsOpen(true)
|
|
}
|
|
|
|
const handleBlur = () => {
|
|
// Close the dropdown as soon as focus leaves, so it never lingers open over
|
|
// the rows below when focus advances via keyboard (Enter/Tab).
|
|
setIsOpen(false)
|
|
// Small delay to allow dropdown click to fire first. Keep any 4-digit
|
|
// numeric value even if it's not in the currently-active chart — the
|
|
// submit handler will prompt to activate it.
|
|
setTimeout(() => {
|
|
const isFourDigit = /^\d{4}$/.test(search)
|
|
if (!isFourDigit && !accounts.some(a => a.account_number === search)) {
|
|
setSearch(value)
|
|
}
|
|
}, 150)
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<Input
|
|
ref={setInputRef}
|
|
value={search}
|
|
onChange={handleInputChange}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Sök konto…"
|
|
className={`font-mono ${className ?? ''}`.trim()}
|
|
autoComplete="off"
|
|
/>
|
|
|
|
|
|
{/* Dropdown */}
|
|
{isOpen && flatList.length > 0 && (
|
|
<div
|
|
ref={listRef}
|
|
className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
|
|
>
|
|
{groupedAccounts.map((group) => (
|
|
<div key={group.className}>
|
|
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
|
|
{group.className}
|
|
</div>
|
|
{group.accounts.map((item) => {
|
|
const flatIndex = flatList.indexOf(item)
|
|
const isHighlighted = flatIndex === highlightedIndex
|
|
return (
|
|
<button
|
|
key={item.account_number}
|
|
type="button"
|
|
data-highlighted={isHighlighted}
|
|
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
|
|
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
|
}`}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
selectAccount(item.account_number)
|
|
}}
|
|
onMouseEnter={() => setHighlightedIndex(flatIndex)}
|
|
>
|
|
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
|
|
{item.account_number}
|
|
</span>
|
|
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
|
|
{!item.isActive && (
|
|
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
|
|
{notActivatedLabel}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{isOpen && search.trim() && flatList.length === 0 && (
|
|
<div className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] rounded-md border border-input bg-card shadow-md p-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Hittade inget konto som matchar.
|
|
</p>
|
|
{/^\d{4}$/.test(search.trim()) ? (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Om det är ett giltigt BAS-konto aktiveras det när du bokför.
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Kontot kan behöva aktiveras i din kontoplan.
|
|
</p>
|
|
)}
|
|
{onCreateAccount && (
|
|
<button
|
|
type="button"
|
|
className="mt-2 flex w-full items-center gap-2 rounded-md border border-input bg-card px-2 py-1.5 text-left text-sm hover:bg-muted/50"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
setIsOpen(false)
|
|
onCreateAccount(search.trim())
|
|
}}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="truncate">Skapa konto "{search.trim()}"</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|