Add fuzzy search to expense account selector

Users can now type to search accounts instead of manually navigating
the hierarchy. Uses existing FuzzySearch component with Fuse.js.
Search matches against account names and descriptions. The hierarchical
browser remains available when the search input is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-26 13:04:26 -04:00
parent fd5eb9824e
commit 4a89c02dbd

View file

@ -1,124 +1,174 @@
<template>
<div class="space-y-4">
<!-- Breadcrumb showing current path -->
<div v-if="selectedPath.length > 0" class="flex items-center gap-2 text-sm">
<Button
variant="ghost"
size="sm"
@click="navigateToRoot"
class="h-7 px-2"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Start
</Button>
<ChevronRight class="h-4 w-4 text-muted-foreground" />
<div class="flex items-center gap-2">
<span
v-for="(node, index) in selectedPath"
:key="node.account.id"
class="flex items-center gap-2"
>
<Button
variant="ghost"
size="sm"
@click="navigateToLevel(index)"
class="h-7 px-2 text-muted-foreground hover:text-foreground"
>
{{ getAccountDisplayName(node.account.name) }}
</Button>
<ChevronRight
v-if="index < selectedPath.length - 1"
class="h-4 w-4 text-muted-foreground"
/>
</span>
</div>
</div>
<!-- Fuzzy search input -->
<FuzzySearch
:data="allLeafAccounts"
:options="searchOptions"
placeholder="Search accounts..."
:show-result-count="false"
@results="handleSearchResults"
@search="handleSearchQuery"
/>
<!-- Account selection list -->
<div class="border border-border rounded-lg bg-card">
<!-- Loading state -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading accounts...</span>
</div>
<!-- Error state -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 px-4"
>
<AlertCircle class="h-8 w-8 text-destructive mb-2" />
<p class="text-sm text-destructive">{{ error }}</p>
<Button
variant="outline"
size="sm"
@click="loadAccounts"
class="mt-4"
>
<RefreshCw class="h-4 w-4 mr-2" />
Retry
</Button>
</div>
<!-- Account list -->
<div v-else-if="currentNodes.length > 0" class="divide-y divide-border">
<!-- Search results mode -->
<div v-if="isSearching" class="border border-border rounded-lg bg-card">
<div v-if="searchResults.length > 0" class="divide-y divide-border">
<button
v-for="node in currentNodes"
:key="node.account.id"
@click="selectNode(node)"
v-for="account in searchResults"
:key="account.id"
@click="selectAccount(account)"
type="button"
class="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left"
>
<div class="flex items-center gap-3">
<Folder
v-if="node.account.has_children"
class="h-5 w-5 text-primary"
/>
<FileText
v-else
class="h-5 w-5 text-muted-foreground"
/>
<FileText class="h-5 w-5 text-muted-foreground" />
<div>
<p class="font-medium text-foreground">
{{ getAccountDisplayName(node.account.name) }}
{{ getAccountDisplayName(account.name) }}
</p>
<p class="text-xs text-muted-foreground">
{{ formatAccountPath(account.name) }}
</p>
<p
v-if="node.account.description"
v-if="account.description"
class="text-sm text-muted-foreground"
>
{{ node.account.description }}
{{ account.description }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge
v-if="!node.account.has_children"
variant="outline"
class="text-xs"
>
{{ node.account.account_type }}
</Badge>
<ChevronRight
v-if="node.account.has_children"
class="h-5 w-5 text-muted-foreground"
/>
</div>
<Badge variant="outline" class="text-xs">
{{ account.account_type }}
</Badge>
</button>
</div>
<!-- Empty state -->
<div
v-else
class="flex flex-col items-center justify-center py-12 px-4"
>
<Folder class="h-12 w-12 text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No accounts available</p>
<div v-else class="py-8 text-center text-sm text-muted-foreground">
No matching accounts
</div>
</div>
<!-- Browse mode (when not searching) -->
<template v-else>
<!-- Breadcrumb showing current path -->
<div v-if="selectedPath.length > 0" class="flex items-center gap-2 text-sm">
<Button
variant="ghost"
size="sm"
@click="navigateToRoot"
class="h-7 px-2"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Start
</Button>
<ChevronRight class="h-4 w-4 text-muted-foreground" />
<div class="flex items-center gap-2">
<span
v-for="(node, index) in selectedPath"
:key="node.account.id"
class="flex items-center gap-2"
>
<Button
variant="ghost"
size="sm"
@click="navigateToLevel(index)"
class="h-7 px-2 text-muted-foreground hover:text-foreground"
>
{{ getAccountDisplayName(node.account.name) }}
</Button>
<ChevronRight
v-if="index < selectedPath.length - 1"
class="h-4 w-4 text-muted-foreground"
/>
</span>
</div>
</div>
<!-- Account selection list -->
<div class="border border-border rounded-lg bg-card">
<!-- Loading state -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading accounts...</span>
</div>
<!-- Error state -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 px-4"
>
<AlertCircle class="h-8 w-8 text-destructive mb-2" />
<p class="text-sm text-destructive">{{ error }}</p>
<Button
variant="outline"
size="sm"
@click="loadAccounts"
class="mt-4"
>
<RefreshCw class="h-4 w-4 mr-2" />
Retry
</Button>
</div>
<!-- Account list -->
<div v-else-if="currentNodes.length > 0" class="divide-y divide-border">
<button
v-for="node in currentNodes"
:key="node.account.id"
@click="selectNode(node)"
type="button"
class="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left"
>
<div class="flex items-center gap-3">
<Folder
v-if="node.account.has_children"
class="h-5 w-5 text-primary"
/>
<FileText
v-else
class="h-5 w-5 text-muted-foreground"
/>
<div>
<p class="font-medium text-foreground">
{{ getAccountDisplayName(node.account.name) }}
</p>
<p
v-if="node.account.description"
class="text-sm text-muted-foreground"
>
{{ node.account.description }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge
v-if="!node.account.has_children"
variant="outline"
class="text-xs"
>
{{ node.account.account_type }}
</Badge>
<ChevronRight
v-if="node.account.has_children"
class="h-5 w-5 text-muted-foreground"
/>
</div>
</button>
</div>
<!-- Empty state -->
<div
v-else
class="flex flex-col items-center justify-center py-12 px-4"
>
<Folder class="h-12 w-12 text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No accounts available</p>
</div>
</div>
</template>
<!-- Selected account display -->
<div
v-if="selectedAccount"
@ -140,6 +190,8 @@
import { ref, computed, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { FuzzySearch } from '@/components/ui/fuzzy-search'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
import {
ChevronLeft,
ChevronRight,
@ -179,25 +231,90 @@ const accountHierarchy = ref<AccountNode[]>([])
const selectedPath = ref<AccountNode[]>([])
const selectedAccount = ref<Account | null>(props.modelValue ?? null)
// Search state
const isSearching = ref(false)
const searchResults = ref<Account[]>([])
// Fuzzy search config
const searchOptions: FuzzySearchOptions<Account> = {
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'description', weight: 0.3 }
],
threshold: 0.4,
ignoreLocation: true,
minMatchCharLength: 1
},
resultLimit: 20,
minSearchLength: 1,
matchAllWhenSearchEmpty: false
}
// Flatten hierarchy to get all selectable (leaf) accounts
const allLeafAccounts = computed(() => {
const accounts: Account[] = []
const traverse = (nodes: AccountNode[]) => {
for (const node of nodes) {
if (!node.account.has_children) {
accounts.push(node.account)
}
if (node.children.length > 0) {
traverse(node.children)
}
}
}
traverse(accountHierarchy.value)
return accounts
})
// Current nodes to display (either root or children of selected node)
const currentNodes = computed(() => {
if (selectedPath.value.length === 0) {
return accountHierarchy.value
}
const lastNode = selectedPath.value[selectedPath.value.length - 1]
return lastNode.children
})
/**
* Get display name from full account path
* e.g., "Expenses:Groceries:Organic" -> "Organic"
*/
function getAccountDisplayName(fullName: string): string {
const parts = fullName.split(':')
return parts[parts.length - 1]
}
/**
* Format full account path for display
*/
function formatAccountPath(fullName: string): string {
return fullName.split(':').join(' > ')
}
/**
* Handle search query changes
*/
function handleSearchQuery(query: string) {
isSearching.value = query.length > 0
}
/**
* Handle search results from FuzzySearch
*/
function handleSearchResults(results: Account[]) {
searchResults.value = results
}
/**
* Select an account directly (from search results)
*/
function selectAccount(account: Account) {
selectedAccount.value = account
emit('update:modelValue', account)
emit('account-selected', account)
}
/**
* Load accounts from API
*/
@ -206,20 +323,16 @@ async function loadAccounts() {
error.value = null
try {
// Get wallet key from first wallet (invoice key for read operations)
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
throw new Error('No wallet available. Please log in.')
}
// Filter by user permissions to only show authorized accounts
accountHierarchy.value = await expensesAPI.getAccountHierarchy(
wallet.inkey,
props.rootAccount,
true // filterByUser
true
)
console.log('[AccountSelector] Loaded user-authorized accounts:', accountHierarchy.value)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load accounts'
console.error('[AccountSelector] Error loading accounts:', err)
@ -229,19 +342,15 @@ async function loadAccounts() {
}
/**
* Handle node selection
* Handle node selection in browse mode
*/
function selectNode(node: AccountNode) {
if (node.account.has_children) {
// Navigate into folder
selectedPath.value.push(node)
selectedAccount.value = null
emit('update:modelValue', null)
} else {
// Select leaf account
selectedAccount.value = node.account
emit('update:modelValue', node.account)
emit('account-selected', node.account)
selectAccount(node.account)
}
}
@ -263,7 +372,6 @@ function navigateToLevel(level: number) {
emit('update:modelValue', null)
}
// Load accounts on mount
onMounted(() => {
loadAccounts()
})