From 75dfd8a54112e55ff4efb1701502facc169ed2ab Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 23:06:58 +0200 Subject: [PATCH] refactor(libra): redesign transactions list status + type encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../expenses/views/TransactionsPage.vue | 197 ++++++++++-------- 1 file changed, 115 insertions(+), 82 deletions(-) diff --git a/src/modules/expenses/views/TransactionsPage.vue b/src/modules/expenses/views/TransactionsPage.vue index fbec2d9..a2772e6 100644 --- a/src/modules/expenses/views/TransactionsPage.vue +++ b/src/modules/expenses/views/TransactionsPage.vue @@ -12,10 +12,6 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { - CheckCircle2, - Clock, - Flag, - XCircle, RefreshCw, Calendar, Filter @@ -30,14 +26,26 @@ const isLoading = ref(false) const dateRangeType = ref(15) // 15, 30, 60, or 'custom' const customStartDate = ref('') const customEndDate = ref('') -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 = [ - { label: 'All', value: 'all' as const }, - { label: 'Income', value: 'income' as const }, - { label: 'Expenses', value: 'expense' as const } +const activeCategories = ref>(new Set(['income', 'expense'])) + +const categoryChips: { label: string; value: Category }[] = [ + { 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 { return t.tags?.includes('income-entry') ?? false } @@ -50,6 +58,18 @@ function isVoided(t: Transaction): boolean { 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) // Fuzzy search state and configuration @@ -75,12 +95,13 @@ const searchOptions: FuzzySearchOptions = { 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 base = searchResults.value.length > 0 ? searchResults.value : transactions.value - if (typeFilter.value === 'income') return base.filter(isIncome) - if (typeFilter.value === 'expense') return base.filter(isExpense) - return base + return base.filter(t => { + const bucket = getBucket(t) + return bucket !== null && activeCategories.value.has(bucket) + }) }) // Handle search results @@ -112,23 +133,28 @@ function formatAmount(amount: number): string { return new Intl.NumberFormat('en-US').format(amount) } -// Get status icon and color. Voided entries (per libra convention) keep -// flag='!' and carry a 'voided' tag — surface that as the icon regardless -// of the underlying flag. -function getStatusInfo(transaction: Transaction) { - if (isVoided(transaction)) { - return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' } - } - switch (transaction.flag) { - case '*': - return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' } - case '!': - return { icon: Clock, color: 'text-orange-600', label: 'Pending' } - case '#': - return { icon: Flag, color: 'text-red-600', label: 'Flagged' } - default: - return null - } +// Income gets a leading '+', expense a leading '-'. +function getAmountSign(t: Transaction): string { + if (isIncome(t)) return '+' + if (isExpense(t)) return '-' + return '' +} + +// Color tint for the amount text. Voided entries drop to muted regardless +// of type since the strike-through carries the "ignore this" signal. +function getAmountColorClass(t: Transaction): string { + if (isVoided(t)) return 'line-through text-muted-foreground' + if (isIncome(t)) return 'text-green-600 dark:text-green-400' + if (isExpense(t)) return 'text-red-600 dark:text-red-400' + return '' +} + +// 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 @@ -231,19 +257,20 @@ onMounted(() => { - +
@@ -298,7 +325,7 @@ onMounted(() => { Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }} - {{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }} + {{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }} @@ -314,7 +341,9 @@ onMounted(() => {

No transactions found

- {{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }} + + +

@@ -323,34 +352,19 @@ onMounted(() => {
-
- - -

- {{ transaction.description }} -

-
+

+ {{ transaction.description }} +

{{ formatDate(transaction.date) }}

@@ -358,22 +372,17 @@ onMounted(() => {
-

- {{ formatAmount(transaction.amount) }} sats +

+ {{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats

- {{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }} + {{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}

@@ -390,20 +399,44 @@ onMounted(() => { Ref: {{ transaction.reference }}
- -
+ +
+ Income + + + Expense + + + Voided + + + Pending approval + + - {{ tag === 'voided' ? 'Voided' : tag }} + {{ tag }}