diff --git a/static/js/index.js b/static/js/index.js index 6c722b3..e8a31e2 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -212,15 +212,14 @@ window.app = Vue.createApp({ loading: false, machine: null, settlements: [], - // Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm / - // submitCassettePublish methods + the cassettes panel in - // templates/spirekeeper/index.html. + // Cassettes sub-tab state (v2, bitspire ADR-004) — see + // openCassetteOpDialog / submitCassetteOp + the cassettes panel in + // templates/spirekeeper/index.html. Read-only by design: the + // operator records operations, the machine owns the counts. activeTab: 'settlements', - cassetteEdits: [], // editable working copy of cassette_configs rows - cassettesPristine: [], // last-known-clean snapshot for revert + cassettes: [], // machine-reported rows, not editable + cassetteOps: [], // recent operations, newest first cassettesLoading: false, - cassettesPublishing: false, - cassettesDirty: false, cassettesError: null }, cassettesTable: { @@ -228,14 +227,27 @@ window.app = Vue.createApp({ {name: 'position', label: 'Bay', field: 'position', align: 'right'}, {name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'}, {name: 'count', label: 'Count', field: 'count', align: 'right'}, - {name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'}, - {name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'} + {name: 'state_at', label: 'Machine reported', field: 'state_at', align: 'left'}, + {name: 'actions', label: '', field: 'position', align: 'right'} ], pagination: {rowsPerPage: 0} // hide pagination — cassette count is small }, - cassettePublishConfirm: { - show: false + cassetteOpDialog: { + show: false, + saving: false, + error: null, + position: null, + op_type: 'refill', + bills: null, + count: null, + denomination: null }, + cassetteOpTypeOptions: [ + {label: 'Refill — notes added', value: 'refill'}, + {label: 'Empty — bay emptied', value: 'empty'}, + {label: 'Recount — notes counted', value: 'recount'}, + {label: 'Set denomination', value: 'set_denomination'} + ], partialDispenseDialog: { show: false, saving: false, @@ -286,6 +298,28 @@ window.app = Vue.createApp({ }, computed: { + cassetteBayOptions() { + return this.machineDetail.cassettes.map(row => ({ + label: `Bay ${row.position} — ${row.denomination} ${ + (this.machineDetail.machine || {}).fiat_code || '' + } ×${row.count}`, + value: row.position + })) + }, + + cassetteOpIsComplete() { + // Mirrors CreateCassetteOpData's root validator: exactly one value + // field, decided by the type. Enforced here only to keep the button + // honest — the server rejects a malformed op regardless. + const d = this.cassetteOpDialog + if (!d.position) return false + if (d.op_type === 'empty') return true + if (d.op_type === 'refill') return Number(d.bills) > 0 + if (d.op_type === 'recount') return d.count !== null && Number(d.count) >= 0 + if (d.op_type === 'set_denomination') return Number(d.denomination) > 0 + return false + }, + superAnyFee() { // Banner styling key — true when either directional super fee is // non-zero, so the banner reads as "active platform fee" instead @@ -943,9 +977,8 @@ 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.cassettes = [] + this.machineDetail.cassetteOps = [] this.machineDetail.cassettesError = null this.machineDetail.activeTab = 'settlements' this.machineDetail.show = true @@ -972,21 +1005,25 @@ window.app = Vue.createApp({ }, // ----------------------------------------------------------------- - // Cassette inventory (#29 v1) + // Cassette inventory + operations (v2, bitspire ADR-004) // ----------------------------------------------------------------- async loadMachineCassettes() { if (!this.machineDetail.machine) return this.machineDetail.cassettesLoading = true this.machineDetail.cassettesError = null + const base = `${MACHINES_PATH}/${this.machineDetail.machine.id}` 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 + // The machine row is re-read too: counts_uncertain_since lives on it + // and is set by the consumer as state events land, so the row the + // machines table handed us goes stale while this dialog is open. + const [machine, bays, ops] = await Promise.all([ + LNbits.api.request('GET', base), + LNbits.api.request('GET', `${base}/cassettes`), + LNbits.api.request('GET', `${base}/cassettes/ops`) + ]) + if (machine.data) this.machineDetail.machine = machine.data + this.machineDetail.cassettes = bays.data || [] + this.machineDetail.cassetteOps = ops.data || [] } catch (e) { this._notifyError(e, 'Failed to load cassettes') } finally { @@ -994,73 +1031,91 @@ window.app = Vue.createApp({ } }, - markCassetteDirty(row) { - // Find pristine match by position (the row identity) and compare; - // flip _dirty + overall dirty flag accordingly. Editable fields - // are denomination + count; position is the immutable row key. - const pristine = this.machineDetail.cassettesPristine.find( - p => p.position === row.position + cassetteOpIcon(opType) { + return ( + { + refill: 'add_circle_outline', + empty: 'remove_circle_outline', + recount: 'fact_check', + set_denomination: 'sell' + }[opType] || 'help_outline' ) - row._dirty = - !pristine || - Number(row.denomination) !== Number(pristine.denomination) || - Number(row.count) !== Number(pristine.count) - 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 (v1.1, position-keyed): - // { positions: { "": { denomination, count }, ... } } - // The API enforces the position set matches what's stored — - // since we only edit existing rows, this should always pass. - const positions = {} - for (const row of this.machineDetail.cassetteEdits) { - positions[String(row.position)] = { - denomination: Number(row.denomination), - count: Number(row.count) - } + cassetteOpSummary(op) { + const fiat = (this.machineDetail.machine || {}).fiat_code || '' + const bay = `Bay ${op.position}` + if (op.op_type === 'refill') return `${bay} — added ${op.bills} notes` + if (op.op_type === 'empty') return `${bay} — emptied` + if (op.op_type === 'recount') return `${bay} — recounted to ${op.count}` + if (op.op_type === 'set_denomination') { + return `${bay} — denomination set to ${op.denomination} ${fiat}`.trim() } - const payload = {positions} - this.machineDetail.cassettesPublishing = true + return `${bay} — ${op.op_type}` + }, + + openCassetteOpDialog(position) { + const bays = this.machineDetail.cassettes + if (!bays.length) return + Object.assign(this.cassetteOpDialog, { + show: true, + saving: false, + error: null, + position: position || bays[0].position, + op_type: 'refill', + bills: null, + count: null, + denomination: null + }) + }, + + resetCassetteOpValue() { + // Each type carries exactly one value field and the server rejects an + // op that carries a foreign one. Clearing on every type switch means a + // number typed under the previous type can't ride along invisibly. + Object.assign(this.cassetteOpDialog, { + bills: null, + count: null, + denomination: null, + error: null + }) + }, + + async submitCassetteOp() { + const d = this.cassetteOpDialog + if (!this.cassetteOpIsComplete) return + const payload = {position: Number(d.position), op_type: d.op_type} + if (d.op_type === 'refill') payload.bills = Number(d.bills) + if (d.op_type === 'recount') payload.count = Number(d.count) + if (d.op_type === 'set_denomination') { + payload.denomination = Number(d.denomination) + } + d.saving = true + d.error = null try { - const {data} = await LNbits.api.request( + await LNbits.api.request( 'POST', - `${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`, + `${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/ops`, 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 + d.show = false Quasar.Notify.create({ type: 'positive', - message: 'Cassette config published to ATM' + message: 'Operation recorded and published to the ATM' }) + await this.loadMachineCassettes() } 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') + 'Could not record the operation' + // A 503 means the op IS recorded and will ride out with the next + // publish, so reload either way — the list should show it pending. + d.error = detail + this._notifyError(e, 'Operation failed') + await this.loadMachineCassettes() } finally { - this.machineDetail.cassettesPublishing = false + d.saving = false } }, diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index fd26d1c..ad01503 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -1151,23 +1151,22 @@
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. + The machine owns these counts — it holds the notes. You + record what you did to a bay and it keeps the + running total. Bay count and denomination set are + hardware-determined (re-provision via atm-tui to change + the bays themselves).

- - Discard unsaved edits + + Re-read the machine's latest report - +
@@ -1179,64 +1178,108 @@ - + + The machine can't vouch for these counts. + A dispense ended without a reliable count of what left the bay + (). + Open the bay, count it, and record a Recount — the + machine clears this on its own once you do. + + + - Waiting for the ATM's bootstrap state event. Power on the ATM + Waiting for the ATM's state event. Power on the ATM and confirm it has reached the configured relay; cassette rows will auto-populate on receipt. - +
+
Recent operations
+

+ "Pending" means sent but not yet echoed back by the machine. + Every publish carries the recent window, so a pending + operation keeps being re-offered until it lands — there is + nothing to retry by hand. +

+ + No operations recorded yet. + + + + + + + + + + + + + + Machine confirmed at + + + + Sent; waiting for the machine to echo this id back + + + + + +
+ @@ -1244,53 +1287,82 @@ - + - + -
Publish cassette config to ATM
+
Record cassette operation
- + - 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. + Record what you did to the bay. The machine applies it to the + count it already holds, so a dispense that happened while this + dialog was open is kept, not overwritten. + + + + + + + + + + + + + + The bay is now empty. Nothing else to enter. + + + + + -

Sending to ATM:

- - - - - - - - - - - · count - - - - -
+ label="Record + publish" + :disable="!cassetteOpIsComplete" + :loading="cassetteOpDialog.saving" + @click="submitCassetteOp">