refactor(libra): redesign transactions list status + type encoding

Rework how the standalone transactions list communicates entry status
and type so each visual channel does one job and the filter UI matches
the underlying axes.

Encoding:
- Type lives in the signed/colored amount (+green income, -red expense)
  and a matching Income/Expense badge in the badge row.
- Status lives in badges only: red Voided (leftmost) and yellow Pending
  (after the type badge). Cleared entries carry no status badge — the
  quiet default.
- Voided rows additionally strike-through and mute the amount.
- Drop the title-row status icons and the colored left border that
  previously fought with the amount color for the same meaning.

Filter UI:
- Replace the type radio + voided switch with three category chips —
  Income, Expenses, Voided — that independently toggle inclusion of one
  bucket of rows. Each row belongs to exactly one bucket (voided wins
  over type). Defaults: Income + Expenses on, Voided off.
- Empty-selection state nudges the user to enable a category.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-06 23:06:58 +02:00
commit 75dfd8a541

View file

@ -12,10 +12,6 @@ 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
@ -30,14 +26,26 @@ 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>('')
const typeFilter = ref<'all' | 'income' | 'expense'>('all') // Each chip is an inclusion toggle for one bucket of rows. Every row
// 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 typeFilterOptions = [ const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
{ label: 'All', value: 'all' as const },
{ label: 'Income', value: 'income' as const }, const categoryChips: { label: string; value: Category }[] = [
{ label: 'Expenses', value: 'expense' as const } { label: 'Income', value: 'income' },
{ 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
} }
@ -50,6 +58,18 @@ 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
@ -75,12 +95,13 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
matchAllWhenSearchEmpty: true matchAllWhenSearchEmpty: true
} }
// Transactions to display (search results or all transactions), filtered by type // Transactions to display: row passes if its bucket's chip is active.
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
if (typeFilter.value === 'income') return base.filter(isIncome) return base.filter(t => {
if (typeFilter.value === 'expense') return base.filter(isExpense) const bucket = getBucket(t)
return base return bucket !== null && activeCategories.value.has(bucket)
})
}) })
// Handle search results // Handle search results
@ -112,23 +133,28 @@ function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount) return new Intl.NumberFormat('en-US').format(amount)
} }
// Get status icon and color. Voided entries (per libra convention) keep // Income gets a leading '+', expense a leading '-'.
// flag='!' and carry a 'voided' tag surface that as the icon regardless function getAmountSign(t: Transaction): string {
// of the underlying flag. if (isIncome(t)) return '+'
function getStatusInfo(transaction: Transaction) { if (isExpense(t)) return '-'
if (isVoided(transaction)) { return ''
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' } }
}
switch (transaction.flag) { // Color tint for the amount text. Voided entries drop to muted regardless
case '*': // of type since the strike-through carries the "ignore this" signal.
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' } function getAmountColorClass(t: Transaction): string {
case '!': if (isVoided(t)) return 'line-through text-muted-foreground'
return { icon: Clock, color: 'text-orange-600', label: 'Pending' } if (isIncome(t)) return 'text-green-600 dark:text-green-400'
case '#': if (isExpense(t)) return 'text-red-600 dark:text-red-400'
return { icon: Flag, color: 'text-red-600', label: 'Flagged' } return ''
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
@ -231,19 +257,20 @@ onMounted(() => {
</Button> </Button>
</div> </div>
<!-- Type Filter (All / Income / Expenses) --> <!-- Category chips: each chip toggles inclusion of one bucket
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="option in typeFilterOptions" v-for="chip in categoryChips"
:key="option.value" :key="chip.value"
:variant="typeFilter === option.value ? 'default' : 'outline'" :variant="activeCategories.has(chip.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="typeFilter = option.value" @click="toggleCategory(chip.value)"
:disabled="isLoading" :disabled="isLoading"
> >
{{ option.label }} {{ chip.label }}
</Button> </Button>
</div> </div>
@ -298,7 +325,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>
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }} {{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span> </span>
</div> </div>
@ -314,7 +341,9 @@ 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">
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }} <template v-if="searchResults.length > 0">Try a different search term</template>
<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>
@ -323,34 +352,19 @@ onMounted(() => {
<div <div
v-for="transaction in transactionsToDisplay" v-for="transaction in transactionsToDisplay"
:key="transaction.id" :key="transaction.id"
:class="[ class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
'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', 'font-medium text-sm sm:text-base truncate mb-1',
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>
@ -358,22 +372,17 @@ onMounted(() => {
<!-- Amount --> <!-- Amount -->
<div class="text-right flex-shrink-0"> <div class="text-right flex-shrink-0">
<p <p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
:class="[ {{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
'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-muted-foreground', 'text-xs',
isVoided(transaction) && 'line-through' getAmountColorClass(transaction) || 'text-muted-foreground'
]" ]"
> >
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }} {{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p> </p>
</div> </div>
</div> </div>
@ -390,20 +399,44 @@ onMounted(() => {
<span class="font-medium">Ref:</span> {{ transaction.reference }} <span class="font-medium">Ref:</span> {{ transaction.reference }}
</div> </div>
<!-- Tags --> <!-- Badges: type (Income / Expense) + status (Voided / Pending,
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2"> mutually exclusive) + any user-added tags. -->
<div class="flex flex-wrap gap-1 mt-2">
<Badge <Badge
v-for="tag in transaction.tags" v-if="isIncome(transaction)"
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="[ class="text-xs"
'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 === 'voided' ? 'Voided' : tag }} {{ tag }}
</Badge> </Badge>
</div> </div>
</div> </div>