feat(v2): super-fee edit + Worklist + Reports (P9f+g, completes P9)

Combines the final three P9 pieces into a single commit since each is
small and they share the JS state plumbing.

Super-fee edit (P9f — visible only to super_user):
  - "Edit" affordance on the platform-fee banner, gated on
    g.user.super_user (LNbits passes this through windowMixin)
  - Modal: super_fee_pct (decimal 0..1) + super_fee_wallet_id (text)
  - PUT /api/v1/dca/super-config (check_super_user on the backend)
  - Operators see the same banner read-only — no edit button rendered

Worklist tab (P9g part 1):
  - Reuses GET /api/v1/dca/settlements/stuck?threshold_minutes=N
  - Three labeled buckets: errored / stuck_pending / stuck_processing,
    each with row count chip
  - Per-row actions: open machine detail (reuses viewMachine), retry
    (for errored), force-reset (for stuck — confirmation dialog warns
    only-use-if-truly-stuck)
  - Threshold input (default 30 min) + manual refresh button
  - "All clear" green banner when worklist is empty
  - Auto-loads on `created()` so the badge count is accurate from boot

Reports tab (P9g part 2):
  - Four CSV download cards: machines / clients / deposits / payments
  - Clients CSV merges in the per-LP balance summary from clientBalances
    so the export captures total_deposits/payments/remaining + currency
  - Payments CSV lazy-loads from GET /api/v1/dca/payments since payments
    aren't cached in dashboard state (could be many rows)
  - _downloadCsv helper properly quotes/escapes values with embedded
    commas/quotes/newlines per RFC 4180
  - All exports are client-side; no new endpoint required

P9 is now complete (P9a–P9g). The v1 super-only Lamassu dashboard is
fully replaced. Operators can register machines, manage LPs + deposits,
configure commission splits, work through errored settlements, and
export their data — all against the v2 backend.

Total v2 frontend: ~1326 lines JS + ~1349 lines template, replacing
the v1's 773 + 851. Increase is from the much larger v2 surface
(machines, leg-typed payments, commission editor, worklist, settle-
balance, partial-dispense, notes, force-reset, retry).

Refs: aiolabs/satmachineadmin#9 — completes P9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:09:07 +02:00
commit f4eb7ec928
2 changed files with 420 additions and 7 deletions

View file

@ -67,6 +67,40 @@ window.app = Vue.createApp({
// across the current legs (purely visual; doesn't hit the server). // across the current legs (purely visual; doesn't hit the server).
commissionPreviewInput: 1000, commissionPreviewInput: 1000,
// Worklist (P9g)
worklist: {
errored: [],
stuck_pending: [],
stuck_processing: [],
totalCount: 0
},
worklistLoading: false,
worklistThreshold: 30,
worklistTable: {
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
{
name: 'error_message',
label: 'Error',
field: 'error_message',
align: 'left'
},
{name: 'actions', label: '', field: 'id', align: 'right'}
]
},
// Reports
reportsBusy: false,
// Super-fee edit dialog (super-only)
superFeeDialog: {
show: false,
saving: false,
data: {super_fee_pct: 0, super_fee_wallet_id: ''}
},
// UI configuration ----------------------------------------------- // UI configuration -----------------------------------------------
machinesTable: { machinesTable: {
columns: [ columns: [
@ -226,6 +260,32 @@ window.app = Vue.createApp({
value: c.id value: c.id
})) }))
}, },
worklistBuckets() {
return [
{
key: 'errored',
label: 'Errored — needs retry',
icon: 'error',
color: 'red',
rows: this.worklist.errored
},
{
key: 'stuck_pending',
label: 'Stuck pending — listener crashed before processing?',
icon: 'hourglass_top',
color: 'orange',
rows: this.worklist.stuck_pending
},
{
key: 'stuck_processing',
label: 'Stuck processing — processor crashed mid-flight?',
icon: 'sync_problem',
color: 'purple',
rows: this.worklist.stuck_processing
}
]
},
commissionScopeOptions() { commissionScopeOptions() {
const opts = [{label: 'Default ruleset (operator-wide)', value: null}] const opts = [{label: 'Default ruleset (operator-wide)', value: null}]
for (const m of this.machines) { for (const m of this.machines) {
@ -284,6 +344,7 @@ window.app = Vue.createApp({
async created() { async created() {
await this.refreshAll() await this.refreshAll()
await this.loadCommissionSplits() await this.loadCommissionSplits()
await this.loadWorklist()
}, },
methods: { methods: {
@ -377,8 +438,8 @@ window.app = Vue.createApp({
}, },
async loadWorklistCount() { async loadWorklistCount() {
// Light read used to badge the Worklist tab. The full worklist // Light read for the tab badge — Worklist tab fetches the full
// panel lives in P9g; here we just count for the badge. // payload via loadWorklist when opened.
try { try {
const {data} = await LNbits.api.request('GET', STUCK_PATH) const {data} = await LNbits.api.request('GET', STUCK_PATH)
this.worklistCount = this.worklistCount =
@ -390,6 +451,162 @@ window.app = Vue.createApp({
} }
}, },
async loadWorklist() {
this.worklistLoading = true
try {
const {data} = await LNbits.api.request(
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
)
this.worklist.errored = data?.errored || []
this.worklist.stuck_pending = data?.stuck_pending || []
this.worklist.stuck_processing = data?.stuck_processing || []
this.worklist.totalCount =
this.worklist.errored.length +
this.worklist.stuck_pending.length +
this.worklist.stuck_processing.length
this.worklistCount = this.worklist.totalCount
} catch (e) {
this._notifyError(e, 'Failed to load worklist')
} finally {
this.worklistLoading = false
}
},
async viewMachineFromWorklist(settlement) {
const machine = this.machinesById[settlement.machine_id]
if (!machine) return
await this.viewMachine(machine)
},
confirmRetryFromWorklist(settlement) {
this.confirmRetrySettlement(settlement)
// Drop from worklist on success (optimistic; reload covers re-eval).
setTimeout(() => this.loadWorklist(), 500)
},
confirmForceResetFromWorklist(settlement) {
this.confirmForceReset(settlement)
setTimeout(() => this.loadWorklist(), 500)
},
// -----------------------------------------------------------------
// Super-fee edit (P9f — super-only)
// -----------------------------------------------------------------
openSuperFeeDialog() {
this.superFeeDialog.data = {
super_fee_pct: this.superConfig?.super_fee_pct ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
}
this.superFeeDialog.show = true
},
async submitSuperFee() {
const d = this.superFeeDialog.data
this.superFeeDialog.saving = true
try {
const {data} = await LNbits.api.request(
'PUT', SUPER_FEE_PATH, null,
{
super_fee_pct: Number(d.super_fee_pct),
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
}
)
this.superConfig = data
this.superFeeDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Platform fee updated'})
} catch (e) {
this._notifyError(e, 'Save failed')
} finally {
this.superFeeDialog.saving = false
}
},
// -----------------------------------------------------------------
// Reports / CSV exports (P9g)
// -----------------------------------------------------------------
downloadMachinesCsv() {
this._downloadCsv(
'machines.csv',
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
'is_active', 'fallback_commission_pct', 'created_at'],
this.machines
)
},
downloadClientsCsv() {
const rows = this.clients.map(c => {
const bal = this.clientBalances[c.id] || {}
return {
...c,
machine_name: this.machineNameById(c.machine_id),
remaining_balance: bal.remaining_balance ?? '',
total_deposits: bal.total_deposits ?? '',
total_payments: bal.total_payments ?? '',
balance_currency: bal.currency ?? ''
}
})
this._downloadCsv(
'clients.csv',
['id', 'machine_id', 'machine_name', 'user_id', 'wallet_id',
'username', 'dca_mode', 'status', 'autoforward_enabled',
'autoforward_ln_address', 'total_deposits', 'total_payments',
'remaining_balance', 'balance_currency', 'created_at'],
rows
)
},
downloadDepositsCsv() {
this._downloadCsv(
'deposits.csv',
['id', 'client_id', 'machine_id', 'creator_user_id', 'amount',
'currency', 'status', 'notes', 'created_at', 'confirmed_at'],
this.deposits
)
},
async downloadPaymentsCsv() {
// Payments are not pre-loaded; fetch on demand.
this.reportsBusy = true
try {
const {data} = await LNbits.api.request('GET', `${API}/payments`)
this._downloadCsv(
'payments.csv',
['id', 'settlement_id', 'client_id', 'machine_id', 'leg_type',
'destination_wallet_id', 'destination_ln_address', 'amount_sats',
'amount_fiat', 'exchange_rate', 'status', 'external_payment_hash',
'transaction_time', 'created_at', 'error_message'],
data || []
)
} catch (e) {
this._notifyError(e, 'Failed to fetch payments')
} finally {
this.reportsBusy = false
}
},
_downloadCsv(filename, columns, rows) {
const escape = v => {
if (v == null) return ''
const s = String(v)
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
return s
}
const header = columns.join(',')
const body = rows.map(
row => columns.map(col => escape(row[col])).join(',')
).join('\n')
const csv = header + '\n' + body
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Add machine // Add machine
// ----------------------------------------------------------------- // -----------------------------------------------------------------

View file

@ -43,6 +43,14 @@
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm"> <span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
Your remainder splits per the rules below. Your remainder splits per the rules below.
</span> </span>
<template v-slot:action>
<q-btn v-if="g?.user?.super_user"
flat dense color="blue" icon="edit"
label="Edit"
@click="openSuperFeeDialog">
<q-tooltip>Super-only: set platform fee + destination wallet</q-tooltip>
</q-btn>
</template>
</q-banner> </q-banner>
<!-- Main tab strip --> <!-- Main tab strip -->
@ -548,14 +556,167 @@
</q-card> </q-card>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="worklist"> <q-tab-panel name="worklist">
<q-banner class="bg-grey-2 text-grey-9"> <div class="row items-center q-mb-md">
Worklist (stuck / errored settlements) — pending P9g. <div class="col">
<h6 class="q-my-none">Worklist</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Settlements that didn't process cleanly. Errored ones need
retry; stuck ones may need force-reset (processor crashed
mid-flight).
</p>
</div>
<div class="col-auto row items-center q-gutter-sm">
<q-input v-model.number="worklistThreshold"
label="Threshold (min)" dense outlined
:style="{width: '120px'}"
type="number" min="1" />
<q-btn flat dense color="primary" icon="refresh"
:loading="worklistLoading"
@click="loadWorklist" />
</div>
</div>
<q-banner v-if="worklist.totalCount === 0"
class="bg-green-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="check_circle" color="green" />
</template>
All clear — no errored or stuck settlements.
</q-banner> </q-banner>
<div v-for="bucket in worklistBuckets" :key="bucket.key"
v-show="bucket.rows.length"
class="q-mb-lg">
<div class="row items-center q-mb-sm">
<q-icon :name="bucket.icon" :color="bucket.color" size="sm"
class="q-mr-sm" />
<span :style="{fontWeight: 500}" v-text="bucket.label"></span>
<q-chip dense
:color="bucket.color" text-color="white"
class="q-ml-sm"
v-text="bucket.rows.length"></q-chip>
</div>
<q-table dense flat
:rows="bucket.rows"
row-key="id"
:columns="worklistTable.columns"
hide-pagination
:pagination="{rowsPerPage: 0}">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="machine">
<span v-text="machineNameById(props.row.machine_id)"></span>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="gross_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span>
</q-td>
<q-td key="error_message">
<span :style="{fontSize: '0.85em', opacity: 0.8}"
v-text="props.row.error_message || '—'"></span>
</q-td>
<q-td key="actions" auto-width>
<q-btn flat dense size="sm" icon="open_in_new"
color="primary"
@click="viewMachineFromWorklist(props.row)">
<q-tooltip>Open machine detail</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'errored'"
flat dense size="sm" icon="restart_alt"
color="primary"
@click="confirmRetryFromWorklist(props.row)">
<q-tooltip>Retry distribution</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key !== 'errored'"
flat dense size="sm" icon="local_fire_department"
color="red"
@click="confirmForceResetFromWorklist(props.row)">
<q-tooltip>Force-reset (stuck)</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="reports"> <q-tab-panel name="reports">
<q-banner class="bg-grey-2 text-grey-9"> <div class="row items-center q-mb-md">
Reports / CSV exports — pending P9g. <div class="col">
</q-banner> <h6 class="q-my-none">Reports</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Client-side CSV exports of the data currently loaded in the
dashboard. For larger date ranges or server-side filters,
use the LNbits API directly.
</p>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Machines</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="machines.length"></span> rows
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="machines.csv"
@click="downloadMachinesCsv" />
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Clients (LPs)</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="clients.length"></span> rows, balances included
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="clients.csv"
@click="downloadClientsCsv" />
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Deposits</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="deposits.length"></span> rows
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="deposits.csv"
@click="downloadDepositsCsv" />
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Payments (legs)</div>
<div class="text-caption" :style="{opacity: 0.7}">
Distribution audit (dca / super_fee / operator_split / etc)
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="payments.csv"
:loading="reportsBusy"
@click="downloadPaymentsCsv" />
</q-card-actions>
</q-card>
</div>
</div>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
@ -889,6 +1050,41 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- =============================================================== -->
<!-- SUPER-FEE EDIT DIALOG (super-only) -->
<!-- =============================================================== -->
<q-dialog v-model="superFeeDialog.show" persistent>
<q-card :style="{minWidth: '460px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Platform fee (super-only)</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Charged on every operator's commission across the LNbits instance.
Operators see this as a read-only banner. Wallet ID is where the
collected fee lands; typically a wallet you (the super) own.
</p>
<q-input v-model.number="superFeeDialog.data.super_fee_pct"
label="Fee % (decimal, 0..1)"
hint="0.30 = 30% of every operator's commission"
type="number" step="0.0001" min="0" max="1"
class="q-mb-md" dense outlined />
<q-input v-model="superFeeDialog.data.super_fee_wallet_id"
label="Super fee destination wallet_id"
hint="LNbits wallet that collects the platform fee"
class="q-mb-md" dense outlined />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn color="primary" label="Save"
:loading="superFeeDialog.saving"
@click="submitSuperFee" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== --> <!-- =============================================================== -->
<!-- ADD / EDIT DEPOSIT DIALOG --> <!-- ADD / EDIT DEPOSIT DIALOG -->
<!-- =============================================================== --> <!-- =============================================================== -->