Compare commits
No commits in common. "1f20d5f00ce6964535102f9b64843d6b1c2a7304" and "4af220adda0389b2d4debce251e813d3f2e2b0a3" have entirely different histories.
1f20d5f00c
...
4af220adda
1 changed files with 82 additions and 115 deletions
|
|
@ -12,6 +12,10 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Flag,
|
||||||
|
XCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Calendar,
|
Calendar,
|
||||||
Filter
|
Filter
|
||||||
|
|
@ -26,26 +30,14 @@ const isLoading = ref(false)
|
||||||
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
||||||
const customStartDate = ref<string>('')
|
const customStartDate = ref<string>('')
|
||||||
const customEndDate = ref<string>('')
|
const customEndDate = ref<string>('')
|
||||||
// Each chip is an inclusion toggle for one bucket of rows. Every row
|
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
|
||||||
// belongs to exactly one bucket (voided rows go to 'voided' regardless
|
|
||||||
// of their income/expense type). Default hides voided.
|
|
||||||
type Category = 'income' | 'expense' | 'voided'
|
|
||||||
|
|
||||||
const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
|
const typeFilterOptions = [
|
||||||
|
{ label: 'All', value: 'all' as const },
|
||||||
const categoryChips: { label: string; value: Category }[] = [
|
{ label: 'Income', value: 'income' as const },
|
||||||
{ label: 'Income', value: 'income' },
|
{ label: 'Expenses', value: 'expense' as const }
|
||||||
{ label: 'Expenses', value: 'expense' },
|
|
||||||
{ label: 'Voided', value: 'voided' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function toggleCategory(cat: Category) {
|
|
||||||
const next = new Set(activeCategories.value)
|
|
||||||
if (next.has(cat)) next.delete(cat)
|
|
||||||
else next.add(cat)
|
|
||||||
activeCategories.value = next
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIncome(t: Transaction): boolean {
|
function isIncome(t: Transaction): boolean {
|
||||||
return t.tags?.includes('income-entry') ?? false
|
return t.tags?.includes('income-entry') ?? false
|
||||||
}
|
}
|
||||||
|
|
@ -58,18 +50,6 @@ function isVoided(t: Transaction): boolean {
|
||||||
return t.tags?.includes('voided') ?? false
|
return t.tags?.includes('voided') ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPending(t: Transaction): boolean {
|
|
||||||
return t.flag === '!' && !isVoided(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which chip bucket a row falls into. Voided always wins over type.
|
|
||||||
function getBucket(t: Transaction): Category | null {
|
|
||||||
if (isVoided(t)) return 'voided'
|
|
||||||
if (isIncome(t)) return 'income'
|
|
||||||
if (isExpense(t)) return 'expense'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||||
|
|
||||||
// Fuzzy search state and configuration
|
// Fuzzy search state and configuration
|
||||||
|
|
@ -95,13 +75,12 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
|
||||||
matchAllWhenSearchEmpty: true
|
matchAllWhenSearchEmpty: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions to display: row passes if its bucket's chip is active.
|
// Transactions to display (search results or all transactions), filtered by type
|
||||||
const transactionsToDisplay = computed(() => {
|
const transactionsToDisplay = computed(() => {
|
||||||
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
||||||
return base.filter(t => {
|
if (typeFilter.value === 'income') return base.filter(isIncome)
|
||||||
const bucket = getBucket(t)
|
if (typeFilter.value === 'expense') return base.filter(isExpense)
|
||||||
return bucket !== null && activeCategories.value.has(bucket)
|
return base
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle search results
|
// Handle search results
|
||||||
|
|
@ -133,28 +112,23 @@ function formatAmount(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US').format(amount)
|
return new Intl.NumberFormat('en-US').format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Income gets a leading '+', expense a leading '-'.
|
// Get status icon and color. Voided entries (per libra convention) keep
|
||||||
function getAmountSign(t: Transaction): string {
|
// flag='!' and carry a 'voided' tag — surface that as the icon regardless
|
||||||
if (isIncome(t)) return '+'
|
// of the underlying flag.
|
||||||
if (isExpense(t)) return '-'
|
function getStatusInfo(transaction: Transaction) {
|
||||||
return ''
|
if (isVoided(transaction)) {
|
||||||
}
|
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
|
||||||
|
}
|
||||||
// Color tint for the amount text. Voided entries drop to muted regardless
|
switch (transaction.flag) {
|
||||||
// of type since the strike-through carries the "ignore this" signal.
|
case '*':
|
||||||
function getAmountColorClass(t: Transaction): string {
|
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
|
||||||
if (isVoided(t)) return 'line-through text-muted-foreground'
|
case '!':
|
||||||
if (isIncome(t)) return 'text-green-600 dark:text-green-400'
|
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
|
||||||
if (isExpense(t)) return 'text-red-600 dark:text-red-400'
|
case '#':
|
||||||
return ''
|
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
|
||||||
}
|
default:
|
||||||
|
return null
|
||||||
// Tags that drive other visual channels (border / sign / strike-through) —
|
}
|
||||||
// suppressed from the badge row so it only carries user-added tags.
|
|
||||||
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
|
|
||||||
|
|
||||||
function getDisplayTags(t: Transaction): string[] {
|
|
||||||
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load transactions
|
// Load transactions
|
||||||
|
|
@ -257,20 +231,19 @@ onMounted(() => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category chips: each chip toggles inclusion of one bucket
|
<!-- Type Filter (All / Income / Expenses) -->
|
||||||
of rows. Defaults: Income + Expenses on, Voided off. -->
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<Filter class="h-4 w-4 text-muted-foreground" />
|
<Filter class="h-4 w-4 text-muted-foreground" />
|
||||||
<Button
|
<Button
|
||||||
v-for="chip in categoryChips"
|
v-for="option in typeFilterOptions"
|
||||||
:key="chip.value"
|
:key="option.value"
|
||||||
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
|
:variant="typeFilter === option.value ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 md:h-8 px-3 text-xs"
|
class="h-7 md:h-8 px-3 text-xs"
|
||||||
@click="toggleCategory(chip.value)"
|
@click="typeFilter = option.value"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
{{ chip.label }}
|
{{ option.label }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -325,7 +298,7 @@ onMounted(() => {
|
||||||
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -341,9 +314,7 @@ onMounted(() => {
|
||||||
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
||||||
<p class="text-muted-foreground">No transactions found</p>
|
<p class="text-muted-foreground">No transactions found</p>
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
<p class="text-sm text-muted-foreground mt-2">
|
||||||
<template v-if="searchResults.length > 0">Try a different search term</template>
|
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
|
||||||
<template v-else-if="activeCategories.size === 0">Select a category above to see transactions</template>
|
|
||||||
<template v-else>Try selecting a different time period or toggling more categories</template>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -352,19 +323,34 @@ onMounted(() => {
|
||||||
<div
|
<div
|
||||||
v-for="transaction in transactionsToDisplay"
|
v-for="transaction in transactionsToDisplay"
|
||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
:class="[
|
||||||
|
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
|
||||||
|
isIncome(transaction) && 'border-l-4 border-l-green-600',
|
||||||
|
isExpense(transaction) && 'border-l-4 border-l-red-600'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<!-- Transaction Header -->
|
<!-- Transaction Header -->
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<!-- Status Icon -->
|
||||||
|
<component
|
||||||
|
v-if="getStatusInfo(transaction)"
|
||||||
|
:is="getStatusInfo(transaction)!.icon"
|
||||||
|
:class="[
|
||||||
|
'h-4 w-4 flex-shrink-0',
|
||||||
|
getStatusInfo(transaction)!.color
|
||||||
|
]"
|
||||||
|
/>
|
||||||
<h3
|
<h3
|
||||||
:class="[
|
:class="[
|
||||||
'font-medium text-sm sm:text-base truncate mb-1',
|
'font-medium text-sm sm:text-base truncate',
|
||||||
isVoided(transaction) && 'line-through text-muted-foreground'
|
isVoided(transaction) && 'line-through text-muted-foreground'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ transaction.description }}
|
{{ transaction.description }}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<p class="text-xs sm:text-sm text-muted-foreground">
|
<p class="text-xs sm:text-sm text-muted-foreground">
|
||||||
{{ formatDate(transaction.date) }}
|
{{ formatDate(transaction.date) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -372,17 +358,22 @@ onMounted(() => {
|
||||||
|
|
||||||
<!-- Amount -->
|
<!-- Amount -->
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0">
|
||||||
<p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
|
<p
|
||||||
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
|
:class="[
|
||||||
|
'font-semibold text-sm sm:text-base',
|
||||||
|
isVoided(transaction) && 'line-through text-muted-foreground'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ formatAmount(transaction.amount) }} sats
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="transaction.fiat_amount"
|
v-if="transaction.fiat_amount"
|
||||||
:class="[
|
:class="[
|
||||||
'text-xs',
|
'text-xs text-muted-foreground',
|
||||||
getAmountColorClass(transaction) || 'text-muted-foreground'
|
isVoided(transaction) && 'line-through'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -399,44 +390,20 @@ onMounted(() => {
|
||||||
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Badges: type (Income / Expense) + status (Voided / Pending,
|
<!-- Tags -->
|
||||||
mutually exclusive) + any user-added tags. -->
|
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
||||||
<div class="flex flex-wrap gap-1 mt-2">
|
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isIncome(transaction)"
|
v-for="tag in transaction.tags"
|
||||||
variant="secondary"
|
|
||||||
class="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
|
||||||
>
|
|
||||||
Income
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-else-if="isExpense(transaction)"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
|
|
||||||
>
|
|
||||||
Expense
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="isVoided(transaction)"
|
|
||||||
variant="outline"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Voided
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-else-if="isPending(transaction)"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300"
|
|
||||||
>
|
|
||||||
Pending approval
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-for="tag in getDisplayTags(transaction)"
|
|
||||||
:key="tag"
|
:key="tag"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-xs"
|
:class="[
|
||||||
|
'text-xs',
|
||||||
|
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300',
|
||||||
|
tag === 'voided' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag === 'voided' ? 'Voided' : tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue