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:
parent
fd5eb9824e
commit
4a89c02dbd
1 changed files with 221 additions and 113 deletions
|
|
@ -1,124 +1,174 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Breadcrumb showing current path -->
|
<!-- Fuzzy search input -->
|
||||||
<div v-if="selectedPath.length > 0" class="flex items-center gap-2 text-sm">
|
<FuzzySearch
|
||||||
<Button
|
:data="allLeafAccounts"
|
||||||
variant="ghost"
|
:options="searchOptions"
|
||||||
size="sm"
|
placeholder="Search accounts..."
|
||||||
@click="navigateToRoot"
|
:show-result-count="false"
|
||||||
class="h-7 px-2"
|
@results="handleSearchResults"
|
||||||
>
|
@search="handleSearchQuery"
|
||||||
<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 -->
|
<!-- Search results mode -->
|
||||||
<div class="border border-border rounded-lg bg-card">
|
<div v-if="isSearching" class="border border-border rounded-lg bg-card">
|
||||||
<!-- Loading state -->
|
<div v-if="searchResults.length > 0" class="divide-y divide-border">
|
||||||
<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
|
<button
|
||||||
v-for="node in currentNodes"
|
v-for="account in searchResults"
|
||||||
:key="node.account.id"
|
:key="account.id"
|
||||||
@click="selectNode(node)"
|
@click="selectAccount(account)"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left"
|
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">
|
<div class="flex items-center gap-3">
|
||||||
<Folder
|
<FileText class="h-5 w-5 text-muted-foreground" />
|
||||||
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>
|
<div>
|
||||||
<p class="font-medium text-foreground">
|
<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>
|
||||||
<p
|
<p
|
||||||
v-if="node.account.description"
|
v-if="account.description"
|
||||||
class="text-sm text-muted-foreground"
|
class="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ node.account.description }}
|
{{ account.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<Badge variant="outline" class="text-xs">
|
||||||
<Badge
|
{{ account.account_type }}
|
||||||
v-if="!node.account.has_children"
|
</Badge>
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="py-8 text-center text-sm text-muted-foreground">
|
||||||
<!-- Empty state -->
|
No matching accounts
|
||||||
<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>
|
||||||
</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 -->
|
<!-- Selected account display -->
|
||||||
<div
|
<div
|
||||||
v-if="selectedAccount"
|
v-if="selectedAccount"
|
||||||
|
|
@ -140,6 +190,8 @@
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { FuzzySearch } from '@/components/ui/fuzzy-search'
|
||||||
|
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -179,25 +231,90 @@ const accountHierarchy = ref<AccountNode[]>([])
|
||||||
const selectedPath = ref<AccountNode[]>([])
|
const selectedPath = ref<AccountNode[]>([])
|
||||||
const selectedAccount = ref<Account | null>(props.modelValue ?? null)
|
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)
|
// Current nodes to display (either root or children of selected node)
|
||||||
const currentNodes = computed(() => {
|
const currentNodes = computed(() => {
|
||||||
if (selectedPath.value.length === 0) {
|
if (selectedPath.value.length === 0) {
|
||||||
return accountHierarchy.value
|
return accountHierarchy.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastNode = selectedPath.value[selectedPath.value.length - 1]
|
const lastNode = selectedPath.value[selectedPath.value.length - 1]
|
||||||
return lastNode.children
|
return lastNode.children
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get display name from full account path
|
* Get display name from full account path
|
||||||
* e.g., "Expenses:Groceries:Organic" -> "Organic"
|
|
||||||
*/
|
*/
|
||||||
function getAccountDisplayName(fullName: string): string {
|
function getAccountDisplayName(fullName: string): string {
|
||||||
const parts = fullName.split(':')
|
const parts = fullName.split(':')
|
||||||
return parts[parts.length - 1]
|
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
|
* Load accounts from API
|
||||||
*/
|
*/
|
||||||
|
|
@ -206,20 +323,16 @@ async function loadAccounts() {
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get wallet key from first wallet (invoice key for read operations)
|
|
||||||
const wallet = user.value?.wallets?.[0]
|
const wallet = user.value?.wallets?.[0]
|
||||||
if (!wallet || !wallet.inkey) {
|
if (!wallet || !wallet.inkey) {
|
||||||
throw new Error('No wallet available. Please log in.')
|
throw new Error('No wallet available. Please log in.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by user permissions to only show authorized accounts
|
|
||||||
accountHierarchy.value = await expensesAPI.getAccountHierarchy(
|
accountHierarchy.value = await expensesAPI.getAccountHierarchy(
|
||||||
wallet.inkey,
|
wallet.inkey,
|
||||||
props.rootAccount,
|
props.rootAccount,
|
||||||
true // filterByUser
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('[AccountSelector] Loaded user-authorized accounts:', accountHierarchy.value)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load accounts'
|
error.value = err instanceof Error ? err.message : 'Failed to load accounts'
|
||||||
console.error('[AccountSelector] Error loading accounts:', err)
|
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) {
|
function selectNode(node: AccountNode) {
|
||||||
if (node.account.has_children) {
|
if (node.account.has_children) {
|
||||||
// Navigate into folder
|
|
||||||
selectedPath.value.push(node)
|
selectedPath.value.push(node)
|
||||||
selectedAccount.value = null
|
selectedAccount.value = null
|
||||||
emit('update:modelValue', null)
|
emit('update:modelValue', null)
|
||||||
} else {
|
} else {
|
||||||
// Select leaf account
|
selectAccount(node.account)
|
||||||
selectedAccount.value = node.account
|
|
||||||
emit('update:modelValue', node.account)
|
|
||||||
emit('account-selected', node.account)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,7 +372,6 @@ function navigateToLevel(level: number) {
|
||||||
emit('update:modelValue', null)
|
emit('update:modelValue', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load accounts on mount
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue