feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30

Merged
padreug merged 19 commits from feat/cassette-config-v1 into v2-bitspire 2026-05-31 13:54:19 +00:00
2 changed files with 283 additions and 3 deletions
Showing only changes of commit 407149137a - Show all commits

feat(v2)(ui): cassette sub-tab in machine detail + overwrite-confirm modal (#29 v1)
Some checks failed
ci.yml / feat(v2)(ui): cassette sub-tab in machine detail + overwrite-confirm modal (#29 v1) (pull_request) Failing after 0s

The operator-facing surface for #29 v1. Two changes:

1. Sub-tab inside the existing machine-detail modal
   (`templates/satmachineadmin/index.html`):
   - q-tabs strip with Settlements + Cassettes inside the machine detail
     q-card-section, wrapping the existing Settlements content in a
     q-tab-panel name="settlements" and adding a new q-tab-panel
     name="cassettes"
   - Cassettes panel renders the cassette_configs rows from
     GET /api/v1/dca/machines/{id}/cassettes:
       - One row per denomination (read-only label)
       - Editable q-input for count + position (the only operator-
         editable fields per the locked design)
       - ATM-reported count column (read-only, shows the v2 reverse-
         channel state_count when populated; v1 only populates on
         bootstrap)
       - Last-updated timestamp
       - Dirty rows highlighted bg-yellow-1
   - "Revert" + "Publish to ATM" buttons in the header; both disabled
     until at least one row is dirty
   - "Waiting for ATM bootstrap" banner when cassette_configs is empty
     (the bootstrap consumer hasn't received the ATM's state event yet)

2. Confirm-on-publish modal (per coord-log `07:50Z`):
   - Yellow warning banner: "This publish will overwrite the ATM's
     currently-tracked counts. If the ATM has dispensed cash since your
     last refill, those decrements will be lost. Publish only after a
     physical refill (a known total), not to 'tweak' counts mid-day. v2
     reconciliation will replace this modal with reconciled state display."
   - Per-denomination preview list of what's being sent
   - Cancel + Publish-to-ATM buttons

Vue 3 + Quasar UMD compliance per workspace CLAUDE.md: explicit-close
tags (no self-closing), v-model.number on the numeric inputs,
@update:model-value to trigger dirty-tracking, JSON-clone for the
pristine snapshot.

JS additions in `static/js/index.js`:
  - machineDetail.cassetteEdits / .cassettesPristine / .cassettesDirty /
    .cassettesLoading / .cassettesPublishing / .cassettesError state
  - cassettesTable.columns (no pagination — small fleets)
  - cassettePublishConfirm.show
  - loadMachineCassettes — fetches + sets pristine snapshot
  - markCassetteDirty — compares to pristine, toggles _dirty + the
    overall cassettesDirty flag
  - revertCassetteEdits — deep-clone pristine back into edits
  - openCassettePublishConfirm — opens the modal
  - submitCassettePublish — builds PublishCassettesPayload from edits,
    POSTs to /machines/{id}/cassettes/publish, refreshes from the
    response, closes modal on success, surfaces 400/503 errors in
    the inline banner

reloadMachineDetail now also calls loadMachineCassettes so the
Cassettes tab is pre-populated and tab-switching is flicker-free.
viewMachine resets the cassette state (edits, pristine, dirty, error,
activeTab) on each open.

This is the final commit in the #29 v1 chain. PR #30 is ready for
review once the build + manual smoke pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Padreug 2026-05-30 18:26:05 +02:00

View file

@ -191,7 +191,30 @@ window.app = Vue.createApp({
show: false, show: false,
loading: false, loading: false,
machine: null, machine: null,
settlements: [] settlements: [],
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
// submitCassettePublish methods + the cassettes panel in
// templates/satmachineadmin/index.html.
activeTab: 'settlements',
cassetteEdits: [], // editable working copy of cassette_configs rows
cassettesPristine: [], // last-known-clean snapshot for revert
cassettesLoading: false,
cassettesPublishing: false,
cassettesDirty: false,
cassettesError: null
},
cassettesTable: {
columns: [
{name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'},
{name: 'count', label: 'Count', field: 'count', align: 'right'},
{name: 'position', label: 'Position', field: 'position', align: 'right'},
{name: 'state_count', label: 'ATM-reported', field: 'state_count', align: 'right'},
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
},
cassettePublishConfirm: {
show: false
}, },
partialDispenseDialog: { partialDispenseDialog: {
show: false, show: false,
@ -741,6 +764,11 @@ window.app = Vue.createApp({
async viewMachine(machine) { async viewMachine(machine) {
this.machineDetail.machine = machine this.machineDetail.machine = machine
this.machineDetail.settlements = [] this.machineDetail.settlements = []
this.machineDetail.cassetteEdits = []
this.machineDetail.cassettesPristine = []
this.machineDetail.cassettesDirty = false
this.machineDetail.cassettesError = null
this.machineDetail.activeTab = 'settlements'
this.machineDetail.show = true this.machineDetail.show = true
await this.reloadMachineDetail() await this.reloadMachineDetail()
}, },
@ -759,6 +787,101 @@ window.app = Vue.createApp({
} finally { } finally {
this.machineDetail.loading = false this.machineDetail.loading = false
} }
// Cassettes load in parallel; UI only renders them when the tab
// is active, but pre-loading means no flicker on tab switch.
await this.loadMachineCassettes()
},
// -----------------------------------------------------------------
// Cassette inventory (#29 v1)
// -----------------------------------------------------------------
async loadMachineCassettes() {
if (!this.machineDetail.machine) return
this.machineDetail.cassettesLoading = true
this.machineDetail.cassettesError = null
try {
const {data} = await LNbits.api.request(
'GET',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes`
)
const rows = (data || []).map(row => ({...row, _dirty: false}))
this.machineDetail.cassetteEdits = rows
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(rows))
this.machineDetail.cassettesDirty = false
} catch (e) {
this._notifyError(e, 'Failed to load cassettes')
} finally {
this.machineDetail.cassettesLoading = false
}
},
markCassetteDirty(row) {
// Find pristine match by denomination and compare; flip _dirty +
// overall dirty flag accordingly.
const pristine = this.machineDetail.cassettesPristine.find(
p => p.denomination === row.denomination
)
row._dirty =
!pristine ||
Number(row.count) !== Number(pristine.count) ||
Number(row.position) !== Number(pristine.position)
this.machineDetail.cassettesDirty =
this.machineDetail.cassetteEdits.some(r => r._dirty)
},
revertCassetteEdits() {
this.machineDetail.cassetteEdits = JSON.parse(
JSON.stringify(this.machineDetail.cassettesPristine)
)
this.machineDetail.cassettesDirty = false
this.machineDetail.cassettesError = null
},
openCassettePublishConfirm() {
if (!this.machineDetail.cassettesDirty) return
this.machineDetail.cassettesError = null
this.cassettePublishConfirm.show = true
},
async submitCassettePublish() {
// Build the PublishCassettesPayload shape:
// { denominations: { "<denom>": { position, count }, ... } }
// The API enforces the denomination set matches what's stored —
// since we only edit existing rows, this should always pass.
const denominations = {}
for (const row of this.machineDetail.cassetteEdits) {
denominations[String(row.denomination)] = {
position: Number(row.position),
count: Number(row.count)
}
}
const payload = {denominations}
this.machineDetail.cassettesPublishing = true
try {
const {data} = await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`,
null,
payload
)
const fresh = (data || []).map(r => ({...r, _dirty: false}))
this.machineDetail.cassetteEdits = fresh
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(fresh))
this.machineDetail.cassettesDirty = false
this.cassettePublishConfirm.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Cassette config published to ATM'
})
} catch (e) {
const detail =
(e && e.response && e.response.data && e.response.data.detail) ||
'Publish failed'
this.machineDetail.cassettesError = detail
this._notifyError(e, 'Publish failed')
} finally {
this.machineDetail.cassettesPublishing = false
}
}, },
settlementStatusColor(status) { settlementStatusColor(status) {

View file

@ -818,7 +818,7 @@
<q-btn flat dense round icon="refresh" <q-btn flat dense round icon="refresh"
@click="reloadMachineDetail" @click="reloadMachineDetail"
:loading="machineDetail.loading"> :loading="machineDetail.loading">
<q-tooltip>Reload settlements</q-tooltip> <q-tooltip>Reload</q-tooltip>
</q-btn> </q-btn>
<q-btn flat dense round icon="close" v-close-popup> <q-btn flat dense round icon="close" v-close-popup>
<q-tooltip>Close</q-tooltip> <q-tooltip>Close</q-tooltip>
@ -845,7 +845,21 @@
<q-separator class="q-mb-md"></q-separator> <q-separator class="q-mb-md"></q-separator>
<div class="row items-center q-mb-sm"> <q-tabs v-model="machineDetail.activeTab" dense
align="left" class="text-grey-7"
active-color="primary" indicator-color="primary"
narrow-indicator>
<q-tab name="settlements" icon="receipt_long"
label="Settlements"></q-tab>
<q-tab name="cassettes" icon="precision_manufacturing"
label="Cassettes"></q-tab>
</q-tabs>
<q-separator></q-separator>
<q-tab-panels v-model="machineDetail.activeTab" animated>
<q-tab-panel name="settlements" class="q-px-none">
<div class="row items-center q-mt-md q-mb-sm">
<div class="col"> <div class="col">
<h6 class="q-my-none">Settlements</h6> <h6 class="q-my-none">Settlements</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}"> <p class="text-caption q-my-none" :style="{opacity: 0.7}">
@ -959,10 +973,153 @@
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>
</q-tab-panel>
<q-tab-panel name="cassettes" class="q-px-none">
<div class="row items-center q-mt-md q-mb-sm">
<div class="col">
<h6 class="q-my-none">Cassettes</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Per-cassette count and physical bay position. Denomination
set is hardware-determined (re-provision via atm-tui to
change). "Publish to ATM" encrypts + signs + sends the new
config to the machine via Nostr.
</p>
</div>
<div class="col-auto">
<q-btn flat dense icon="undo" label="Revert"
:disable="!machineDetail.cassettesDirty"
@click="revertCassetteEdits">
<q-tooltip>Discard unsaved edits</q-tooltip>
</q-btn>
<q-btn color="primary" icon="cloud_upload"
label="Publish to ATM"
:disable="!machineDetail.cassettesDirty"
:loading="machineDetail.cassettesPublishing"
@click="openCassettePublishConfirm"></q-btn>
</div>
</div>
<q-banner v-if="machineDetail.cassettesError"
class="bg-red-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" color="negative"></q-icon>
</template>
<span v-text="machineDetail.cassettesError"></span>
</q-banner>
<q-banner v-if="!machineDetail.cassetteEdits.length
&& !machineDetail.cassettesLoading"
class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="hourglass_empty" color="blue"></q-icon>
</template>
Waiting for the ATM's bootstrap state event. Power on the ATM
and confirm it has reached the configured relay; cassette
rows will auto-populate on receipt.
</q-banner>
<q-table v-if="machineDetail.cassetteEdits.length"
dense flat
:rows="machineDetail.cassetteEdits"
row-key="denomination"
:columns="cassettesTable.columns"
:pagination="cassettesTable.pagination"
hide-pagination>
<template v-slot:body="props">
<q-tr :props="props"
:class="props.row._dirty ? 'bg-yellow-1' : ''">
<q-td key="denomination" class="text-right">
<b v-text="props.row.denomination"></b>
<span :style="{fontSize: '0.85em', opacity: 0.6}"
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
</q-td>
<q-td key="count" class="text-right">
<q-input v-model.number="props.row.count" type="number"
min="0" step="1" dense outlined
:style="{width: '120px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<q-td key="position" class="text-right">
<q-input v-model.number="props.row.position" type="number"
min="1" step="1" dense outlined
:style="{width: '80px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<q-td key="state_count" class="text-right">
<span v-if="props.row.state_count !== null"
:style="{fontSize: '0.85em', opacity: 0.7}"
v-text="props.row.state_count"></span>
<span v-else :style="{opacity: 0.4}"></span>
</q-td>
<q-td key="updated_at">
<span :style="{fontSize: '0.85em', opacity: 0.7}"
v-text="formatTime(props.row.updated_at)"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
</q-tab-panels>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- =============================================================== -->
<!-- CASSETTE PUBLISH CONFIRM DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="cassettePublishConfirm.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Publish cassette config to ATM</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<q-banner class="bg-orange-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" color="warning"></q-icon>
</template>
<b>This publish will overwrite the ATM's currently-tracked
counts.</b> If the ATM has dispensed cash since your last
refill or count baseline, those decrements will be lost.
Publish only after a physical refill (a known total), not to
"tweak" counts mid-day. v2 reconciliation will replace this
modal with reconciled state display.
</q-banner>
<p class="q-mb-sm">Sending to ATM:</p>
<q-list dense bordered>
<q-item v-for="row in machineDetail.cassetteEdits"
:key="row.denomination">
<q-item-section>
<q-item-label>
<b v-text="row.denomination + ' ' +
(machineDetail.machine.fiat_code || '')"></b>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>
position
<b v-text="row.position"></b>
· count
<b v-text="row.count"></b>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
label="Publish to ATM"
:loading="machineDetail.cassettesPublishing"
@click="submitCassettePublish"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== --> <!-- =============================================================== -->
<!-- PARTIAL-DISPENSE DIALOG --> <!-- PARTIAL-DISPENSE DIALOG -->
<!-- =============================================================== --> <!-- =============================================================== -->