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>
This commit is contained in:
Padreug 2026-05-30 18:26:05 +02:00
commit 407149137a
2 changed files with 283 additions and 3 deletions

View file

@ -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: { "<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) {