'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 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 // 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 } const MAX_RESULTS = 50 export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, className }: AccountComboboxProps) { const [search, setSearch] = useState(value) const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(0) const containerRef = useRef(null) const inputRef = useRef(null) const listRef = useRef(null) // Sync external value changes into the search field useEffect(() => { setSearch(value) }, [value]) // Filter accounts based on search input const filteredAccounts = useMemo(() => { if (!search) return accounts.slice(0, MAX_RESULTS) const trimmed = search.trim() if (!trimmed) return accounts.slice(0, MAX_RESULTS) const startsWithDigit = /^\d/.test(trimmed) if (startsWithDigit) { return accounts .filter((a) => a.account_number.startsWith(trimmed)) .slice(0, MAX_RESULTS) } const lowerSearch = trimmed.toLowerCase() return accounts .filter((a) => a.account_name.toLowerCase().includes(lowerSearch)) .slice(0, MAX_RESULTS) }, [accounts, search]) // Group filtered accounts by class const groupedAccounts = useMemo(() => { const groups: { className: string; accounts: BASAccount[] }[] = [] const groupMap = new Map() 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) => { 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. if (newValue !== value) onCommit?.(newValue) } if (!isOpen) { setIsOpen(true) } } const handleFocus = () => { setIsOpen(true) } const handleBlur = () => { // 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 (
{/* Dropdown */} {isOpen && flatList.length > 0 && (
{groupedAccounts.map((group) => (
{group.className}
{group.accounts.map((account) => { const flatIndex = flatList.indexOf(account) const isHighlighted = flatIndex === highlightedIndex return ( ) })}
))}
)} {/* Empty state */} {isOpen && search.trim() && flatList.length === 0 && (

Hittade inget konto som matchar.

{/^\d{4}$/.test(search.trim()) ? (

Om det är ett giltigt BAS-konto aktiveras det när du bokför.

) : (

Kontot kan behöva aktiveras i din kontoplan.

)} {onCreateAccount && ( )}
)}
) }