feat(v2)(ui): cassette sub-tab in machine detail + overwrite-confirm modal (#29 v1)
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:
parent
f8042f8e4d
commit
407149137a
2 changed files with 283 additions and 3 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -818,7 +818,7 @@
|
|||
<q-btn flat dense round icon="refresh"
|
||||
@click="reloadMachineDetail"
|
||||
:loading="machineDetail.loading">
|
||||
<q-tooltip>Reload settlements</q-tooltip>
|
||||
<q-tooltip>Reload</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense round icon="close" v-close-popup>
|
||||
<q-tooltip>Close</q-tooltip>
|
||||
|
|
@ -845,7 +845,21 @@
|
|||
|
||||
<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">
|
||||
<h6 class="q-my-none">Settlements</h6>
|
||||
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
||||
|
|
@ -959,10 +973,153 @@
|
|||
</q-tr>
|
||||
</template>
|
||||
</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>
|
||||
</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 -->
|
||||
<!-- =============================================================== -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue