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).
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 -----------------------------------------------
machinesTable: {
columns: [
@ -226,6 +260,32 @@ window.app = Vue.createApp({
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() {
const opts = [{label: 'Default ruleset (operator-wide)', value: null}]
for (const m of this.machines) {
@ -284,6 +344,7 @@ window.app = Vue.createApp({
async created() {
await this.refreshAll()
await this.loadCommissionSplits()
await this.loadWorklist()
},
methods: {
@ -377,8 +438,8 @@ window.app = Vue.createApp({
},
async loadWorklistCount() {
// Light read used to badge the Worklist tab. The full worklist
// panel lives in P9g; here we just count for the badge.
// Light read for the tab badge — Worklist tab fetches the full
// payload via loadWorklist when opened.
try {
const {data} = await LNbits.api.request('GET', STUCK_PATH)
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
// -----------------------------------------------------------------