Add Recent Transactions pagination and table view to with filtering
Convert the Recent Transactions card from a list view to a paginated table with enhanced filtering capabilities for super users. Frontend changes: - Replace q-list with q-table for better data presentation - Add pagination with configurable page size (default: 20 items) - Add transaction filter dropdown for super users to filter by username - Define table columns: Status, Date, Description, User, Amount, Fiat, Reference - Implement prev/next page navigation with page info display - Add filter controls with clear filter button Backend changes (views_api.py): - Add pagination support with limit/offset parameters - Add filter_user_id parameter for filtering by user (super user only) - Enrich transaction entries with user_id and username from account lookups - Return paginated response with total count and pagination metadata Database changes (crud.py): - Update get_all_journal_entries() to support offset parameter - Update get_journal_entries_by_user() to support offset parameter - Add count_all_journal_entries() for total count - Add count_journal_entries_by_user() for user-specific count This improves the Recent Transactions UX by providing better organization, easier navigation through large transaction lists, and the ability for admins to filter transactions by user.
This commit is contained in:
parent
093cecbff2
commit
f3d0d8652b
3 changed files with 172 additions and 47 deletions
|
|
@ -17,6 +17,9 @@ window.app = Vue.createApp({
|
|||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
transactionFilter: {
|
||||
user_id: null // For filtering by user
|
||||
},
|
||||
accounts: [],
|
||||
currencies: [],
|
||||
users: [],
|
||||
|
|
@ -183,6 +186,17 @@ window.app = Vue.createApp({
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
transactionColumns() {
|
||||
return [
|
||||
{ name: 'flag', label: 'Status', field: 'flag', align: 'left', sortable: true },
|
||||
{ name: 'date', label: 'Date', field: 'entry_date', align: 'left', sortable: true },
|
||||
{ name: 'description', label: 'Description', field: 'description', align: 'left', sortable: false },
|
||||
{ name: 'username', label: 'User', field: 'username', align: 'left', sortable: true },
|
||||
{ name: 'amount', label: 'Amount (sats)', field: 'amount', align: 'right', sortable: false },
|
||||
{ name: 'fiat', label: 'Fiat Amount', field: 'fiat', align: 'right', sortable: false },
|
||||
{ name: 'reference', label: 'Reference', field: 'reference', align: 'left', sortable: false }
|
||||
]
|
||||
},
|
||||
expenseAccounts() {
|
||||
return this.accounts.filter(a => a.account_type === 'expense')
|
||||
},
|
||||
|
|
@ -330,9 +344,15 @@ window.app = Vue.createApp({
|
|||
|
||||
const limit = parseInt(this.transactionPagination.limit) || 20
|
||||
|
||||
// Build query params with filter
|
||||
let queryParams = `limit=${limit}&offset=${currentOffset}`
|
||||
if (this.transactionFilter.user_id) {
|
||||
queryParams += `&filter_user_id=${this.transactionFilter.user_id}`
|
||||
}
|
||||
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/entries/user?limit=${limit}&offset=${currentOffset}`,
|
||||
`/castle/api/v1/entries/user?${queryParams}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
|
||||
|
|
@ -346,6 +366,16 @@ window.app = Vue.createApp({
|
|||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
applyTransactionFilter() {
|
||||
// Reset to first page when applying filter
|
||||
this.transactionPagination.offset = 0
|
||||
this.loadTransactions(0)
|
||||
},
|
||||
clearTransactionFilter() {
|
||||
this.transactionFilter.user_id = null
|
||||
this.transactionPagination.offset = 0
|
||||
this.loadTransactions(0)
|
||||
},
|
||||
nextTransactionsPage() {
|
||||
if (this.transactionPagination.has_next) {
|
||||
const newOffset = this.transactionPagination.offset + this.transactionPagination.limit
|
||||
|
|
|
|||
|
|
@ -335,61 +335,122 @@
|
|||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-list v-if="transactions.length > 0" separator>
|
||||
<q-item v-for="entry in transactions" :key="entry.id">
|
||||
<q-item-section avatar>
|
||||
<!-- Transaction status flag -->
|
||||
<q-icon v-if="entry.flag === '*'" name="check_circle" color="positive" size="sm">
|
||||
|
||||
<!-- Filter Bar (Super User Only) -->
|
||||
<div v-if="isSuperUser" class="row q-gutter-sm q-mb-md items-center">
|
||||
<div class="col-auto" style="min-width: 200px;">
|
||||
<q-select
|
||||
v-model="transactionFilter.user_id"
|
||||
:options="allUserBalances"
|
||||
option-value="user_id"
|
||||
option-label="username"
|
||||
emit-value
|
||||
map-options
|
||||
clearable
|
||||
label="Filter by User"
|
||||
dense
|
||||
outlined
|
||||
@update:model-value="applyTransactionFilter"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
</template>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col-auto" v-if="transactionFilter.user_id">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="clear"
|
||||
label="Clear Filter"
|
||||
@click="clearTransactionFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions Table -->
|
||||
<q-table
|
||||
v-if="transactions.length > 0"
|
||||
:rows="transactions"
|
||||
:columns="transactionColumns"
|
||||
row-key="id"
|
||||
flat
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
hide-pagination
|
||||
>
|
||||
<!-- Status Flag Column -->
|
||||
<template v-slot:body-cell-flag="props">
|
||||
<q-td :props="props">
|
||||
<q-icon v-if="props.row.flag === '*'" name="check_circle" color="positive" size="sm">
|
||||
<q-tooltip>Cleared</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else-if="entry.flag === '!'" name="pending" color="orange" size="sm">
|
||||
<q-icon v-else-if="props.row.flag === '!'" name="pending" color="orange" size="sm">
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else-if="entry.flag === '#'" name="flag" color="red" size="sm">
|
||||
<q-tooltip>Flagged - needs review</q-tooltip>
|
||||
<q-icon v-else-if="props.row.flag === '#'" name="flag" color="red" size="sm">
|
||||
<q-tooltip>Flagged</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else-if="entry.flag === 'x'" name="cancel" color="grey" size="sm">
|
||||
<q-icon v-else-if="props.row.flag === 'x'" name="cancel" color="grey" size="sm">
|
||||
<q-tooltip>Voided</q-tooltip>
|
||||
</q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{% raw %}{{ entry.description }}{% endraw %}
|
||||
<!-- Castle's perspective: Receivables are incoming (green), Payables are outgoing (red) -->
|
||||
<q-badge v-if="isSuperUser && isReceivable(entry)" color="positive" class="q-ml-sm">
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Date Column -->
|
||||
<template v-slot:body-cell-date="props">
|
||||
<q-td :props="props">
|
||||
{% raw %}{{ formatDate(props.row.entry_date) }}{% endraw %}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Description Column -->
|
||||
<template v-slot:body-cell-description="props">
|
||||
<q-td :props="props">
|
||||
<div>
|
||||
{% raw %}{{ props.row.description }}{% endraw %}
|
||||
<q-badge v-if="isSuperUser && isReceivable(props.row)" color="positive" class="q-ml-sm">
|
||||
Receivable
|
||||
</q-badge>
|
||||
<q-badge v-else-if="isSuperUser && isPayable(entry)" color="negative" class="q-ml-sm">
|
||||
<q-badge v-else-if="isSuperUser && isPayable(props.row)" color="negative" class="q-ml-sm">
|
||||
Payable
|
||||
</q-badge>
|
||||
<!-- User's perspective: Receivables are outgoing (red), Payables are incoming (green) -->
|
||||
<q-badge v-else-if="!isSuperUser && isReceivable(entry)" color="negative" class="q-ml-sm">
|
||||
Payable
|
||||
</q-badge>
|
||||
<q-badge v-else-if="!isSuperUser && isPayable(entry)" color="positive" class="q-ml-sm">
|
||||
Receivable
|
||||
</q-badge>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="entry.reference" class="text-grey">
|
||||
Ref: {% raw %}{{ entry.reference }}{% endraw %}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="entry.meta && Object.keys(entry.meta).length > 0" class="text-blue-grey-6">
|
||||
<q-icon name="info" size="xs" class="q-mr-xs"></q-icon>
|
||||
<span v-if="entry.meta.source">Source: {% raw %}{{ entry.meta.source }}{% endraw %}</span>
|
||||
<span v-if="entry.meta.created_via" class="q-ml-sm">Via: {% raw %}{{ entry.meta.created_via }}{% endraw %}</span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label>{% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %}</q-item-label>
|
||||
<q-item-label caption v-if="getEntryFiatAmount(entry)">
|
||||
{% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
<div v-if="props.row.meta && Object.keys(props.row.meta).length > 0" class="text-caption text-grey">
|
||||
<q-icon name="info" size="xs"></q-icon>
|
||||
<span v-if="props.row.meta.source">{% raw %}{{ props.row.meta.source }}{% endraw %}</span>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Username Column -->
|
||||
<template v-slot:body-cell-username="props">
|
||||
<q-td :props="props">
|
||||
{% raw %}{{ props.row.username || '-' }}{% endraw %}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Amount Column -->
|
||||
<template v-slot:body-cell-amount="props">
|
||||
<q-td :props="props">
|
||||
{% raw %}{{ formatSats(getTotalAmount(props.row)) }}{% endraw %}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Fiat Amount Column -->
|
||||
<template v-slot:body-cell-fiat="props">
|
||||
<q-td :props="props">
|
||||
{% raw %}{{ getEntryFiatAmount(props.row) || '-' }}{% endraw %}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Reference Column -->
|
||||
<template v-slot:body-cell-reference="props">
|
||||
<q-td :props="props">
|
||||
<span class="text-grey">{% raw %}{{ props.row.reference || '-' }}{% endraw %}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<div v-else class="text-center q-pa-md text-grey">
|
||||
No transactions yet
|
||||
</div>
|
||||
|
|
|
|||
38
views_api.py
38
views_api.py
|
|
@ -267,10 +267,12 @@ async def api_get_user_entries(
|
|||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
filter_user_id: str = None,
|
||||
) -> dict:
|
||||
"""Get journal entries that affect the current user's accounts"""
|
||||
from lnbits.settings import settings as lnbits_settings
|
||||
from .crud import count_all_journal_entries, count_journal_entries_by_user
|
||||
from lnbits.core.crud.users import get_user
|
||||
from .crud import count_all_journal_entries, count_journal_entries_by_user, get_account
|
||||
|
||||
# If super user, show all journal entries
|
||||
if wallet.wallet.user == lnbits_settings.super_user:
|
||||
|
|
@ -280,8 +282,40 @@ async def api_get_user_entries(
|
|||
entries = await get_journal_entries_by_user(wallet.wallet.user, limit, offset)
|
||||
total = await count_journal_entries_by_user(wallet.wallet.user)
|
||||
|
||||
# Filter by user_id if specified (super user only)
|
||||
if filter_user_id and wallet.wallet.user == lnbits_settings.super_user:
|
||||
entries = [e for e in entries if any(
|
||||
line.account_id in [acc["id"] for acc in await db.fetchall(
|
||||
"SELECT id FROM accounts WHERE user_id = :user_id",
|
||||
{"user_id": filter_user_id}
|
||||
)]
|
||||
for line in e.lines
|
||||
)]
|
||||
total = len(entries)
|
||||
|
||||
# Enrich entries with username information
|
||||
enriched_entries = []
|
||||
for entry in entries:
|
||||
# Find user_id from entry lines (look for user-specific accounts)
|
||||
entry_user_id = None
|
||||
entry_username = None
|
||||
|
||||
for line in entry.lines:
|
||||
account = await get_account(line.account_id)
|
||||
if account and account.user_id:
|
||||
entry_user_id = account.user_id
|
||||
user = await get_user(account.user_id)
|
||||
entry_username = user.username if user and user.username else account.user_id[:16] + "..."
|
||||
break
|
||||
|
||||
enriched_entries.append({
|
||||
**entry.dict(),
|
||||
"user_id": entry_user_id,
|
||||
"username": entry_username,
|
||||
})
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"entries": enriched_entries,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue