feat(v2): machine detail dialog — settlements + per-row actions (P9b)

Adds the operator's primary workspace: a full-screen dialog opened from
any Fleet row that shows the machine's settlement history with action
menus for retry / partial-dispense / force-reset / note-add.

Template (templates/satmachineadmin/index.html):
  - Full-screen Quasar dialog with q-bar header (machine name + fiat
    chip + reload + close)
  - Machine metadata strip: npub (copyable), wallet_id, location,
    fallback_commission_pct
  - Settlements table: status badge, time, gross / net / commission
    (with super/op breakdown beneath), fiat amount, payment_hash short
  - Notes blob expansion under each settlement row (pre-formatted)
  - Per-row action menu (q-btn-dropdown):
      • Add note         — always available
      • Retry            — when status='errored'
      • Partial dispense — when status in {pending, errored}
      • Force-reset      — when status in {pending, processing}
  - Warning icon (⚠) on rows where used_fallback_split=true, namechecking
    aiolabs/lamassu-next#44 in the tooltip
  - Three sub-dialogs:
      • Partial-dispense with fraction/sats toggle + notes input
      • Add-note dialog (free-form, non-empty validation)
      • (Retry/force-reset use Quasar.Dialog inline)

JS (static/js/index.js):
  - viewMachine() opens detail and triggers reloadMachineDetail()
  - GET /api/v1/dca/machines/{id}/settlements feeds the table
  - confirmRetrySettlement → POST .../retry
  - openPartialDispense → POST .../partial-dispense
  - confirmForceReset    → POST .../force-reset
  - openSettlementNote   → POST .../notes
  - _replaceSettlement() updates the table row in-place from PUT/POST
    responses so the operator sees instant feedback without a reload
  - settlementStatusColor() maps statuses to Quasar badge colors
  - formatSats / formatFiat / formatTime helpers; respect locale

Also: added data/ + *.sqlite3 to .gitignore so the
2026-05-14 auth-key leak can't recur from this repo (the equivalent
fix already landed in satmachineclient on the matching branch).

Refs: aiolabs/satmachineadmin#9 — closes the operator-detail-view
gap for #3 (partial dispense) + #4 (settlement) UX

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:01:08 +02:00
commit 13ac33047b
3 changed files with 480 additions and 9 deletions

View file

@ -271,6 +271,258 @@
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
<!-- =============================================================== -->
<q-dialog v-model="machineDetail.show" maximized
transition-show="slide-up" transition-hide="slide-down">
<q-card v-if="machineDetail.machine">
<q-bar class="bg-primary text-white">
<div class="text-h6 q-mr-md"
v-text="machineDetail.machine.name || 'Unnamed machine'"></div>
<q-chip dense color="white" text-color="primary"
v-text="machineDetail.machine.fiat_code"></q-chip>
<q-space />
<q-btn flat dense round icon="refresh"
@click="reloadMachineDetail"
:loading="machineDetail.loading">
<q-tooltip>Reload settlements</q-tooltip>
</q-btn>
<q-btn flat dense round icon="close" v-close-popup>
<q-tooltip>Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-4">
<div class="text-caption" :style="{opacity: 0.6}">npub</div>
<code :style="{fontSize: '0.85em', wordBreak: 'break-all'}"
v-text="machineDetail.machine.machine_npub"></code>
</div>
<div class="col-12 col-md-3">
<div class="text-caption" :style="{opacity: 0.6}">Wallet</div>
<code :style="{fontSize: '0.85em'}"
v-text="machineDetail.machine.wallet_id"></code>
</div>
<div class="col-6 col-md-2">
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
<span v-text="machineDetail.machine.location || '—'"></span>
</div>
<div class="col-6 col-md-3">
<div class="text-caption" :style="{opacity: 0.6}">
Fallback commission %
</div>
<span v-text="(machineDetail.machine.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
</div>
</div>
<q-separator class="q-mb-md" />
<div class="row items-center q-mb-sm">
<div class="col">
<h6 class="q-my-none">Settlements</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Every bitSpire transaction lands here. Click a row's menu for
retry / partial-dispense / notes.
</p>
</div>
</div>
<q-banner v-if="!machineDetail.settlements.length"
class="bg-grey-2 text-grey-9">
No settlements yet. They'll appear when bitSpire pays this machine's
wallet.
</q-banner>
<q-table v-else
dense flat
:rows="machineDetail.settlements"
row-key="id"
:columns="settlementsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="settlementsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status">
<q-badge :color="settlementStatusColor(props.row.status)"
:label="props.row.status" />
<q-icon v-if="props.row.used_fallback_split"
name="warning_amber" color="orange" size="sm"
class="q-ml-xs">
<q-tooltip>
Fallback split — bitSpire didn't supply per-tx
net/fee. See lamassu-next#44.
</q-tooltip>
</q-icon>
</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="net_sats" class="text-right">
<span v-text="formatSats(props.row.net_sats)"></span>
</q-td>
<q-td key="commission_sats" class="text-right">
<span v-text="formatSats(props.row.commission_sats)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}">
super
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
/ op
<span v-text="formatSats(props.row.operator_fee_sats)"></span>
</div>
</q-td>
<q-td key="fiat_amount" class="text-right">
<span v-text="formatFiat(props.row.fiat_amount, props.row.fiat_code)"></span>
</q-td>
<q-td key="payment_hash">
<code :style="{fontSize: '0.8em'}"
v-text="shortId(props.row.payment_hash)"></code>
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" label="" icon="more_vert">
<q-list dense>
<q-item clickable v-close-popup
@click="openSettlementNote(props.row)">
<q-item-section avatar>
<q-icon name="edit_note" />
</q-item-section>
<q-item-section>Add note</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'errored'"
clickable v-close-popup
@click="confirmRetrySettlement(props.row)">
<q-item-section avatar>
<q-icon name="restart_alt" color="primary" />
</q-item-section>
<q-item-section>Retry distribution</q-item-section>
</q-item>
<q-item
v-if="['pending','errored'].includes(props.row.status)"
clickable v-close-popup
@click="openPartialDispense(props.row)">
<q-item-section avatar>
<q-icon name="precision_manufacturing" color="warning" />
</q-item-section>
<q-item-section>Partial dispense…</q-item-section>
</q-item>
<q-item
v-if="['pending','processing'].includes(props.row.status)"
clickable v-close-popup
@click="confirmForceReset(props.row)">
<q-item-section avatar>
<q-icon name="local_fire_department" color="red" />
</q-item-section>
<q-item-section>Force-reset (stuck)…</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
<q-tr v-if="props.row.notes" :props="props" no-hover>
<q-td colspan="100%" class="bg-grey-2">
<pre :style="{whiteSpace: 'pre-wrap', fontSize: '0.8em', margin: 0}"
v-text="props.row.notes"></pre>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- PARTIAL-DISPENSE DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="partialDispenseDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Apply partial dispense</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section v-if="partialDispenseDialog.settlement">
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="info" color="blue" />
</template>
Original gross:
<b v-text="formatSats(partialDispenseDialog.settlement.gross_sats)"></b>.
Provide what was actually dispensed. Sat amounts will scale linearly,
the commission split will recompute, and distribution will re-run.
</q-banner>
<q-tabs v-model="partialDispenseDialog.mode" dense
active-color="primary" indicator-color="primary"
align="justify">
<q-tab name="fraction" label="By fraction (0..1)" />
<q-tab name="sats" label="By exact sats" />
</q-tabs>
<q-tab-panels v-model="partialDispenseDialog.mode" animated>
<q-tab-panel name="fraction" class="q-pa-sm">
<q-input v-model.number="partialDispenseDialog.dispensed_fraction"
label="Dispensed fraction"
hint="e.g. 0.6 means 60% of the original tx was dispensed"
type="number" step="0.01" min="0" max="1"
dense outlined />
</q-tab-panel>
<q-tab-panel name="sats" class="q-pa-sm">
<q-input v-model.number="partialDispenseDialog.dispensed_sats"
label="Dispensed sats"
hint="Exact sat amount actually dispensed (≤ original gross)"
type="number" step="1" min="0"
:max="partialDispenseDialog.settlement.gross_sats"
dense outlined />
</q-tab-panel>
</q-tab-panels>
<q-input v-model="partialDispenseDialog.notes"
label="Reason (recorded in audit memo)"
type="textarea" autogrow
class="q-mt-md"
dense outlined />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn color="warning"
label="Apply partial dispense"
:loading="partialDispenseDialog.saving"
@click="submitPartialDispense" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD-NOTE DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="noteDialog.show" persistent>
<q-card :style="{minWidth: '420px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Add note to settlement</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-sm" :style="{opacity: 0.7}">
Notes are append-only and timestamped. Use for reconciliation context,
off-LN refund records, dispute narrative, etc.
</p>
<q-input v-model="noteDialog.note"
label="Note"
type="textarea" autogrow
:rules="[v => (v && v.trim().length > 0) || 'Note cannot be empty']"
dense outlined />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn color="primary" label="Save note"
:loading="noteDialog.saving"
@click="submitNote" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) -->
<!-- =============================================================== -->