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 37 additions and 33 deletions
Showing only changes of commit 3014962563 - Show all commits

refactor(v2)(ui): denomination editable per slot, position read-only (#29 v1.1)

UI flips to position-keyed per the v1.1 redesign:

  - Column order: Bay | Denomination | Count | ATM-reported | Updated
    (position first, since it's the row identity)
  - Position becomes read-only: rendered as "Bay N" label
  - Denomination becomes an editable q-input (with the fiat code as a
    suffix on the input)
  - Count remains editable
  - ATM-reported column now shows "<denom> <fiat> · ×<count>" combining
    state_denomination + state_count for at-a-glance reconciliation
    (still v1: only the bootstrap snapshot; v2 reverse-channel makes
    this live)
  - Confirm-modal preview list: header is "Bay N", side shows the
    denomination + count being sent

JS:
  - cassettesTable.columns reordered to put position first
  - markCassetteDirty pivots on position (the immutable identity) and
    compares denomination + count against pristine
  - submitCassettePublish builds {positions: {<pos>: {denomination,
    count}}} payload instead of {denominations: ...}

No "lock icon" on denomination — the previous instinct to add one was
based on the m007 misinterpretation. v1.1 design correctly makes
denomination operator-editable.

Tests still red until commit f.

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

View file

@ -205,10 +205,10 @@ window.app = Vue.createApp({
}, },
cassettesTable: { cassettesTable: {
columns: [ columns: [
{name: 'position', label: 'Bay', field: 'position', align: 'right'},
{name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'}, {name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'},
{name: 'count', label: 'Count', field: 'count', align: 'right'}, {name: 'count', label: 'Count', field: 'count', align: 'right'},
{name: 'position', label: 'Position', field: 'position', align: 'right'}, {name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'},
{name: 'state_count', label: 'ATM-reported', field: 'state_count', align: 'right'},
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'} {name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
], ],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
@ -816,15 +816,16 @@ window.app = Vue.createApp({
}, },
markCassetteDirty(row) { markCassetteDirty(row) {
// Find pristine match by denomination and compare; flip _dirty + // Find pristine match by position (the row identity) and compare;
// overall dirty flag accordingly. // flip _dirty + overall dirty flag accordingly. Editable fields
// are denomination + count; position is the immutable row key.
const pristine = this.machineDetail.cassettesPristine.find( const pristine = this.machineDetail.cassettesPristine.find(
p => p.denomination === row.denomination p => p.position === row.position
) )
row._dirty = row._dirty =
!pristine || !pristine ||
Number(row.count) !== Number(pristine.count) || Number(row.denomination) !== Number(pristine.denomination) ||
Number(row.position) !== Number(pristine.position) Number(row.count) !== Number(pristine.count)
this.machineDetail.cassettesDirty = this.machineDetail.cassettesDirty =
this.machineDetail.cassetteEdits.some(r => r._dirty) this.machineDetail.cassetteEdits.some(r => r._dirty)
}, },
@ -844,18 +845,18 @@ window.app = Vue.createApp({
}, },
async submitCassettePublish() { async submitCassettePublish() {
// Build the PublishCassettesPayload shape: // Build the PublishCassettesPayload shape (v1.1, position-keyed):
// { denominations: { "<denom>": { position, count }, ... } } // { positions: { "<pos>": { denomination, count }, ... } }
// The API enforces the denomination set matches what's stored — // The API enforces the position set matches what's stored —
// since we only edit existing rows, this should always pass. // since we only edit existing rows, this should always pass.
const denominations = {} const positions = {}
for (const row of this.machineDetail.cassetteEdits) { for (const row of this.machineDetail.cassetteEdits) {
denominations[String(row.denomination)] = { positions[String(row.position)] = {
position: Number(row.position), denomination: Number(row.denomination),
count: Number(row.count) count: Number(row.count)
} }
} }
const payload = {denominations} const payload = {positions}
this.machineDetail.cassettesPublishing = true this.machineDetail.cassettesPublishing = true
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(

View file

@ -1023,7 +1023,7 @@
<q-table v-if="machineDetail.cassetteEdits.length" <q-table v-if="machineDetail.cassetteEdits.length"
dense flat dense flat
:rows="machineDetail.cassetteEdits" :rows="machineDetail.cassetteEdits"
row-key="denomination" row-key="position"
:columns="cassettesTable.columns" :columns="cassettesTable.columns"
:pagination="cassettesTable.pagination" :pagination="cassettesTable.pagination"
hide-pagination> hide-pagination>
@ -1032,10 +1032,15 @@
:style="props.row._dirty :style="props.row._dirty
? {boxShadow: 'inset 4px 0 0 0 #fdd835'} ? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
: {}"> : {}">
<q-td key="position" class="text-right">
<b v-text="'Bay ' + props.row.position"></b>
</q-td>
<q-td key="denomination" class="text-right"> <q-td key="denomination" class="text-right">
<b v-text="props.row.denomination"></b> <q-input v-model.number="props.row.denomination"
<span :style="{fontSize: '0.85em', opacity: 0.6}" type="number" min="1" step="1" dense outlined
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span> :suffix="machineDetail.machine.fiat_code || ''"
:style="{width: '140px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td> </q-td>
<q-td key="count" class="text-right"> <q-td key="count" class="text-right">
<q-input v-model.number="props.row.count" type="number" <q-input v-model.number="props.row.count" type="number"
@ -1043,16 +1048,15 @@
:style="{width: '120px', display: 'inline-block'}" :style="{width: '120px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input> @update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td> </q-td>
<q-td key="position" class="text-right"> <q-td key="state" class="text-right">
<q-input v-model.number="props.row.position" type="number" <span v-if="props.row.state_denomination !== null"
min="1" step="1" dense outlined :style="{fontSize: '0.85em', opacity: 0.7}">
:style="{width: '80px', display: 'inline-block'}" <span v-text="props.row.state_denomination"></span>
@update:model-value="markCassetteDirty(props.row)"></q-input> <span :style="{opacity: 0.6}"
</q-td> v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
<q-td key="state_count" class="text-right"> <span :style="{opacity: 0.6}"> · </span>
<span v-if="props.row.state_count !== null" <span v-text="'×' + props.row.state_count"></span>
:style="{fontSize: '0.85em', opacity: 0.7}" </span>
v-text="props.row.state_count"></span>
<span v-else :style="{opacity: 0.4}"></span> <span v-else :style="{opacity: 0.4}"></span>
</q-td> </q-td>
<q-td key="updated_at"> <q-td key="updated_at">
@ -1094,17 +1098,16 @@
<p class="q-mb-sm">Sending to ATM:</p> <p class="q-mb-sm">Sending to ATM:</p>
<q-list dense bordered> <q-list dense bordered>
<q-item v-for="row in machineDetail.cassetteEdits" <q-item v-for="row in machineDetail.cassetteEdits"
:key="row.denomination"> :key="row.position">
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
<b v-text="row.denomination + ' ' + <b v-text="'Bay ' + row.position"></b>
(machineDetail.machine.fiat_code || '')"></b>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-item-label caption> <q-item-label caption>
position <b v-text="row.denomination + ' ' +
<b v-text="row.position"></b> (machineDetail.machine.fiat_code || '')"></b>
· count · count
<b v-text="row.count"></b> <b v-text="row.count"></b>
</q-item-label> </q-item-label>