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

@ -43,6 +43,14 @@
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
Your remainder splits per the rules below.
</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>
<!-- Main tab strip -->
@ -548,14 +556,167 @@
</q-card>
</q-tab-panel>
<q-tab-panel name="worklist">
<q-banner class="bg-grey-2 text-grey-9">
Worklist (stuck / errored settlements) — pending P9g.
<div class="row items-center q-mb-md">
<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>
<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 name="reports">
<q-banner class="bg-grey-2 text-grey-9">
Reports / CSV exports — pending P9g.
</q-banner>
<div class="row items-center q-mb-md">
<div class="col">
<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-panels>
@ -889,6 +1050,41 @@
</q-card>
</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 -->
<!-- =============================================================== -->