From 407149137a9019a5decf4242c84cbcc6a56bbf57 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 18:26:05 +0200 Subject: [PATCH] feat(v2)(ui): cassette sub-tab in machine detail + overwrite-confirm modal (#29 v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- static/js/index.js | 125 ++++++++++++++++++++- templates/satmachineadmin/index.html | 161 ++++++++++++++++++++++++++- 2 files changed, 283 insertions(+), 3 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index cdb493f..ff7cf5b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -191,7 +191,30 @@ window.app = Vue.createApp({ show: false, loading: false, 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: { show: false, @@ -741,6 +764,11 @@ window.app = Vue.createApp({ async viewMachine(machine) { this.machineDetail.machine = machine 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 await this.reloadMachineDetail() }, @@ -759,6 +787,101 @@ window.app = Vue.createApp({ } finally { 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: { "": { 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) { diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 6278ef9..345c643 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -818,7 +818,7 @@ - Reload settlements + Reload Close @@ -845,7 +845,21 @@ -
+ + + + + + + + + +
Settlements

@@ -959,10 +973,153 @@ + + + + +

+
+
Cassettes
+

+ 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. +

+
+
+ + Discard unsaved edits + + +
+
+ + + + + + + + + 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. + + + + + + + + + + + + + + +
Publish cassette config to ATM
+ + +
+ + + + This publish will overwrite the ATM's currently-tracked + counts. 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. + +

Sending to ATM:

+ + + + + + + + + + position + + · count + + + + + +
+ + + + +
+
+