feat(cassettes): record operations from the dashboard instead of counts
The cassettes tab no longer has editable count fields, because there is no longer an endpoint that would accept them. The bays render read-only from the machine's own report, and a Record-operation dialog captures what the operator did: a refill in notes added, an empty, a recount, a denomination change. This removes the failure the tab used to invite. A form loaded before a dispense held a count that was already wrong, and publishing it overwrote the dispense with no error on either side. Recording a delta instead means a dispense that happened while the dialog was open is kept rather than discarded, and a recount is now an explicit act — what an operator opening a bay and counting actually does — rather than being indistinguishable from a stale form. A recent-operations list shows each one as Applied or Pending from acked_at, which is the machine echoing the id back. Pending needs no retry button: every publish carries the recent window, so an operation that missed its own publish keeps being re-offered until it lands, and saying so in the panel is more useful than a button that would do nothing new. The counts-uncertain banner tells the operator when the machine cannot vouch for its own numbers and asks for the recount that clears it. The machine row is re-read on every cassette refresh, since that flag is set by the consumer while the dialog is open.
This commit is contained in:
parent
c76a1bb125
commit
79a4f83293
2 changed files with 275 additions and 148 deletions
|
|
@ -212,15 +212,14 @@ window.app = Vue.createApp({
|
||||||
loading: false,
|
loading: false,
|
||||||
machine: null,
|
machine: null,
|
||||||
settlements: [],
|
settlements: [],
|
||||||
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
|
// Cassettes sub-tab state (v2, bitspire ADR-004) — see
|
||||||
// submitCassettePublish methods + the cassettes panel in
|
// openCassetteOpDialog / submitCassetteOp + the cassettes panel in
|
||||||
// templates/spirekeeper/index.html.
|
// templates/spirekeeper/index.html. Read-only by design: the
|
||||||
|
// operator records operations, the machine owns the counts.
|
||||||
activeTab: 'settlements',
|
activeTab: 'settlements',
|
||||||
cassetteEdits: [], // editable working copy of cassette_configs rows
|
cassettes: [], // machine-reported rows, not editable
|
||||||
cassettesPristine: [], // last-known-clean snapshot for revert
|
cassetteOps: [], // recent operations, newest first
|
||||||
cassettesLoading: false,
|
cassettesLoading: false,
|
||||||
cassettesPublishing: false,
|
|
||||||
cassettesDirty: false,
|
|
||||||
cassettesError: null
|
cassettesError: null
|
||||||
},
|
},
|
||||||
cassettesTable: {
|
cassettesTable: {
|
||||||
|
|
@ -228,14 +227,27 @@ window.app = Vue.createApp({
|
||||||
{name: 'position', label: 'Bay', field: 'position', align: 'right'},
|
{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: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'},
|
{name: 'state_at', label: 'Machine reported', field: 'state_at', align: 'left'},
|
||||||
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
|
{name: 'actions', label: '', field: 'position', align: 'right'}
|
||||||
],
|
],
|
||||||
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
|
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
|
||||||
},
|
},
|
||||||
cassettePublishConfirm: {
|
cassetteOpDialog: {
|
||||||
show: false
|
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: {
|
partialDispenseDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
|
@ -286,6 +298,28 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
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() {
|
superAnyFee() {
|
||||||
// Banner styling key — true when either directional super fee is
|
// Banner styling key — true when either directional super fee is
|
||||||
// non-zero, so the banner reads as "active platform fee" instead
|
// non-zero, so the banner reads as "active platform fee" instead
|
||||||
|
|
@ -943,9 +977,8 @@ window.app = Vue.createApp({
|
||||||
async viewMachine(machine) {
|
async viewMachine(machine) {
|
||||||
this.machineDetail.machine = machine
|
this.machineDetail.machine = machine
|
||||||
this.machineDetail.settlements = []
|
this.machineDetail.settlements = []
|
||||||
this.machineDetail.cassetteEdits = []
|
this.machineDetail.cassettes = []
|
||||||
this.machineDetail.cassettesPristine = []
|
this.machineDetail.cassetteOps = []
|
||||||
this.machineDetail.cassettesDirty = false
|
|
||||||
this.machineDetail.cassettesError = null
|
this.machineDetail.cassettesError = null
|
||||||
this.machineDetail.activeTab = 'settlements'
|
this.machineDetail.activeTab = 'settlements'
|
||||||
this.machineDetail.show = true
|
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() {
|
async loadMachineCassettes() {
|
||||||
if (!this.machineDetail.machine) return
|
if (!this.machineDetail.machine) return
|
||||||
this.machineDetail.cassettesLoading = true
|
this.machineDetail.cassettesLoading = true
|
||||||
this.machineDetail.cassettesError = null
|
this.machineDetail.cassettesError = null
|
||||||
|
const base = `${MACHINES_PATH}/${this.machineDetail.machine.id}`
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
// The machine row is re-read too: counts_uncertain_since lives on it
|
||||||
'GET',
|
// and is set by the consumer as state events land, so the row the
|
||||||
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes`
|
// machines table handed us goes stale while this dialog is open.
|
||||||
)
|
const [machine, bays, ops] = await Promise.all([
|
||||||
const rows = (data || []).map(row => ({...row, _dirty: false}))
|
LNbits.api.request('GET', base),
|
||||||
this.machineDetail.cassetteEdits = rows
|
LNbits.api.request('GET', `${base}/cassettes`),
|
||||||
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(rows))
|
LNbits.api.request('GET', `${base}/cassettes/ops`)
|
||||||
this.machineDetail.cassettesDirty = false
|
])
|
||||||
|
if (machine.data) this.machineDetail.machine = machine.data
|
||||||
|
this.machineDetail.cassettes = bays.data || []
|
||||||
|
this.machineDetail.cassetteOps = ops.data || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._notifyError(e, 'Failed to load cassettes')
|
this._notifyError(e, 'Failed to load cassettes')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -994,73 +1031,91 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
markCassetteDirty(row) {
|
cassetteOpIcon(opType) {
|
||||||
// Find pristine match by position (the row identity) and compare;
|
return (
|
||||||
// flip _dirty + overall dirty flag accordingly. Editable fields
|
{
|
||||||
// are denomination + count; position is the immutable row key.
|
refill: 'add_circle_outline',
|
||||||
const pristine = this.machineDetail.cassettesPristine.find(
|
empty: 'remove_circle_outline',
|
||||||
p => p.position === row.position
|
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() {
|
cassetteOpSummary(op) {
|
||||||
this.machineDetail.cassetteEdits = JSON.parse(
|
const fiat = (this.machineDetail.machine || {}).fiat_code || ''
|
||||||
JSON.stringify(this.machineDetail.cassettesPristine)
|
const bay = `Bay ${op.position}`
|
||||||
)
|
if (op.op_type === 'refill') return `${bay} — added ${op.bills} notes`
|
||||||
this.machineDetail.cassettesDirty = false
|
if (op.op_type === 'empty') return `${bay} — emptied`
|
||||||
this.machineDetail.cassettesError = null
|
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()
|
||||||
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: { "<pos>": { 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const payload = {positions}
|
return `${bay} — ${op.op_type}`
|
||||||
this.machineDetail.cassettesPublishing = true
|
},
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`,
|
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/ops`,
|
||||||
null,
|
null,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
const fresh = (data || []).map(r => ({...r, _dirty: false}))
|
d.show = false
|
||||||
this.machineDetail.cassetteEdits = fresh
|
|
||||||
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(fresh))
|
|
||||||
this.machineDetail.cassettesDirty = false
|
|
||||||
this.cassettePublishConfirm.show = false
|
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Cassette config published to ATM'
|
message: 'Operation recorded and published to the ATM'
|
||||||
})
|
})
|
||||||
|
await this.loadMachineCassettes()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const detail =
|
const detail =
|
||||||
(e && e.response && e.response.data && e.response.data.detail) ||
|
(e && e.response && e.response.data && e.response.data.detail) ||
|
||||||
'Publish failed'
|
'Could not record the operation'
|
||||||
this.machineDetail.cassettesError = detail
|
// A 503 means the op IS recorded and will ride out with the next
|
||||||
this._notifyError(e, 'Publish failed')
|
// publish, so reload either way — the list should show it pending.
|
||||||
|
d.error = detail
|
||||||
|
this._notifyError(e, 'Operation failed')
|
||||||
|
await this.loadMachineCassettes()
|
||||||
} finally {
|
} finally {
|
||||||
this.machineDetail.cassettesPublishing = false
|
d.saving = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1151,23 +1151,22 @@
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h6 class="q-my-none">Cassettes</h6>
|
<h6 class="q-my-none">Cassettes</h6>
|
||||||
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
||||||
Per-cassette count and physical bay position. Denomination
|
The machine owns these counts — it holds the notes. You
|
||||||
set is hardware-determined (re-provision via atm-tui to
|
record what you <i>did</i> to a bay and it keeps the
|
||||||
change). "Publish to ATM" encrypts + signs + sends the new
|
running total. Bay count and denomination set are
|
||||||
config to the machine via Nostr.
|
hardware-determined (re-provision via atm-tui to change
|
||||||
|
the bays themselves).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<q-btn flat dense icon="undo" label="Revert"
|
<q-btn flat dense icon="refresh" label="Refresh"
|
||||||
:disable="!machineDetail.cassettesDirty"
|
:loading="machineDetail.cassettesLoading"
|
||||||
@click="revertCassetteEdits">
|
@click="loadMachineCassettes">
|
||||||
<q-tooltip>Discard unsaved edits</q-tooltip>
|
<q-tooltip>Re-read the machine's latest report</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn color="primary" icon="cloud_upload"
|
<q-btn color="primary" icon="add" label="Record operation"
|
||||||
label="Publish to ATM"
|
:disable="!machineDetail.cassettes.length"
|
||||||
:disable="!machineDetail.cassettesDirty"
|
@click="openCassetteOpDialog()"></q-btn>
|
||||||
:loading="machineDetail.cassettesPublishing"
|
|
||||||
@click="openCassettePublishConfirm"></q-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1179,64 +1178,108 @@
|
||||||
<span v-text="machineDetail.cassettesError"></span>
|
<span v-text="machineDetail.cassettesError"></span>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="!machineDetail.cassetteEdits.length
|
<q-banner v-if="machineDetail.machine
|
||||||
|
&& machineDetail.machine.counts_uncertain_since"
|
||||||
|
class="bg-orange-1 text-grey-9 q-mb-md">
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="help_outline" color="warning"></q-icon>
|
||||||
|
</template>
|
||||||
|
<b>The machine can't vouch for these counts.</b>
|
||||||
|
A dispense ended without a reliable count of what left the bay
|
||||||
|
(<span v-text="formatTime(machineDetail.machine.counts_uncertain_since)"></span>).
|
||||||
|
Open the bay, count it, and record a <b>Recount</b> — the
|
||||||
|
machine clears this on its own once you do.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner v-if="!machineDetail.cassettes.length
|
||||||
&& !machineDetail.cassettesLoading"
|
&& !machineDetail.cassettesLoading"
|
||||||
class="bg-blue-1 text-grey-9">
|
class="bg-blue-1 text-grey-9">
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
<q-icon name="hourglass_empty" color="blue"></q-icon>
|
<q-icon name="hourglass_empty" color="blue"></q-icon>
|
||||||
</template>
|
</template>
|
||||||
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
|
and confirm it has reached the configured relay; cassette
|
||||||
rows will auto-populate on receipt.
|
rows will auto-populate on receipt.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-table v-if="machineDetail.cassetteEdits.length"
|
<q-table v-if="machineDetail.cassettes.length"
|
||||||
dense flat
|
dense flat
|
||||||
:rows="machineDetail.cassetteEdits"
|
:rows="machineDetail.cassettes"
|
||||||
row-key="position"
|
row-key="position"
|
||||||
:columns="cassettesTable.columns"
|
:columns="cassettesTable.columns"
|
||||||
:pagination="cassettesTable.pagination"
|
:pagination="cassettesTable.pagination"
|
||||||
hide-pagination>
|
hide-pagination>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
<q-tr :props="props"
|
<q-tr :props="props">
|
||||||
:style="props.row._dirty
|
|
||||||
? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
|
|
||||||
: {}">
|
|
||||||
<q-td key="position" class="text-right">
|
<q-td key="position" class="text-right">
|
||||||
<b v-text="'Bay ' + props.row.position"></b>
|
<b v-text="'Bay ' + props.row.position"></b>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="denomination" class="text-right">
|
<q-td key="denomination" class="text-right">
|
||||||
<q-input v-model.number="props.row.denomination"
|
<span v-text="props.row.denomination"></span>
|
||||||
type="number" min="1" step="1" dense outlined
|
<span :style="{opacity: 0.6}"
|
||||||
:suffix="machineDetail.machine.fiat_code || ''"
|
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
|
||||||
: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"
|
<b v-text="props.row.count"></b>
|
||||||
min="0" step="1" dense outlined
|
<span :style="{opacity: 0.6}"> notes</span>
|
||||||
:style="{width: '120px', display: 'inline-block'}"
|
|
||||||
@update:model-value="markCassetteDirty(props.row)"></q-input>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="state" class="text-right">
|
<q-td key="state_at">
|
||||||
<span v-if="props.row.state_denomination !== null"
|
|
||||||
:style="{fontSize: '0.85em', opacity: 0.7}">
|
|
||||||
<span v-text="props.row.state_denomination"></span>
|
|
||||||
<span :style="{opacity: 0.6}"
|
|
||||||
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
|
|
||||||
<span :style="{opacity: 0.6}"> · </span>
|
|
||||||
<span v-text="'×' + props.row.state_count"></span>
|
|
||||||
</span>
|
|
||||||
<span v-else :style="{opacity: 0.4}">—</span>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="updated_at">
|
|
||||||
<span :style="{fontSize: '0.85em', opacity: 0.7}"
|
<span :style="{fontSize: '0.85em', opacity: 0.7}"
|
||||||
v-text="formatTime(props.row.updated_at)"></span>
|
v-text="props.row.state_at
|
||||||
|
? formatTime(props.row.state_at)
|
||||||
|
: 'never'"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="actions" class="text-right">
|
||||||
|
<q-btn flat dense size="sm" icon="edit_note"
|
||||||
|
@click="openCassetteOpDialog(props.row.position)">
|
||||||
|
<q-tooltip>Record an operation on this bay</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
|
|
||||||
|
<div v-if="machineDetail.cassettes.length" class="q-mt-lg">
|
||||||
|
<div class="text-subtitle2 q-mb-xs">Recent operations</div>
|
||||||
|
<p class="text-caption q-mt-none q-mb-sm" :style="{opacity: 0.7}">
|
||||||
|
"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.
|
||||||
|
</p>
|
||||||
|
<q-banner v-if="!machineDetail.cassetteOps.length"
|
||||||
|
class="bg-grey-3 text-grey-9">
|
||||||
|
No operations recorded yet.
|
||||||
|
</q-banner>
|
||||||
|
<q-list v-else dense bordered separator>
|
||||||
|
<q-item v-for="op in machineDetail.cassetteOps" :key="op.id">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="cassetteOpIcon(op.op_type)"
|
||||||
|
:color="op.acked_at ? 'positive' : 'grey'"></q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label v-text="cassetteOpSummary(op)"></q-item-label>
|
||||||
|
<q-item-label caption
|
||||||
|
v-text="formatTime(op.created_at)"></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-chip dense size="sm"
|
||||||
|
:color="op.acked_at ? 'green-1' : 'orange-1'"
|
||||||
|
text-color="grey-9"
|
||||||
|
:label="op.acked_at ? 'Applied' : 'Pending'">
|
||||||
|
<q-tooltip v-if="op.acked_at">
|
||||||
|
Machine confirmed at
|
||||||
|
<span v-text="formatTime(op.acked_at)"></span>
|
||||||
|
</q-tooltip>
|
||||||
|
<q-tooltip v-else>
|
||||||
|
Sent; waiting for the machine to echo this id back
|
||||||
|
</q-tooltip>
|
||||||
|
</q-chip>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
@ -1244,53 +1287,82 @@
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- =============================================================== -->
|
<!-- =============================================================== -->
|
||||||
<!-- CASSETTE PUBLISH CONFIRM DIALOG -->
|
<!-- RECORD CASSETTE OPERATION DIALOG -->
|
||||||
<!-- =============================================================== -->
|
<!-- =============================================================== -->
|
||||||
<q-dialog v-model="cassettePublishConfirm.show" persistent>
|
<q-dialog v-model="cassetteOpDialog.show" persistent>
|
||||||
<q-card :style="{width: '480px', maxWidth: '95vw'}">
|
<q-card :style="{width: '480px', maxWidth: '95vw'}">
|
||||||
<q-card-section class="row items-center q-pb-none">
|
<q-card-section class="row items-center q-pb-none">
|
||||||
<div class="text-h6">Publish cassette config to ATM</div>
|
<div class="text-h6">Record cassette operation</div>
|
||||||
<q-space ></q-space>
|
<q-space ></q-space>
|
||||||
<q-btn icon="close" flat round dense v-close-popup></q-btn>
|
<q-btn icon="close" flat round dense v-close-popup></q-btn>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-banner class="bg-orange-1 text-grey-9 q-mb-md">
|
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
<q-icon name="warning" color="warning"></q-icon>
|
<q-icon name="info" color="blue"></q-icon>
|
||||||
</template>
|
</template>
|
||||||
<b>This publish will overwrite the ATM's currently-tracked
|
Record what you did to the bay. The machine applies it to the
|
||||||
counts.</b> If the ATM has dispensed cash since your last
|
count it already holds, so a dispense that happened while this
|
||||||
refill or count baseline, those decrements will be lost.
|
dialog was open is kept, not overwritten.
|
||||||
Publish only after a physical refill (a known total), not to
|
</q-banner>
|
||||||
"tweak" counts mid-day. v2 reconciliation will replace this
|
|
||||||
modal with reconciled state display.
|
<q-select v-model.number="cassetteOpDialog.position"
|
||||||
|
:options="cassetteBayOptions"
|
||||||
|
emit-value map-options
|
||||||
|
label="Bay" dense outlined
|
||||||
|
class="q-mb-md"></q-select>
|
||||||
|
|
||||||
|
<q-select v-model="cassetteOpDialog.op_type"
|
||||||
|
:options="cassetteOpTypeOptions"
|
||||||
|
emit-value map-options
|
||||||
|
label="Operation" dense outlined
|
||||||
|
class="q-mb-md"
|
||||||
|
@update:model-value="resetCassetteOpValue"></q-select>
|
||||||
|
|
||||||
|
<q-input v-if="cassetteOpDialog.op_type === 'refill'"
|
||||||
|
v-model.number="cassetteOpDialog.bills"
|
||||||
|
type="number" min="1" step="1" dense outlined
|
||||||
|
label="Notes added"
|
||||||
|
hint="How many notes you put IN — a delta, not a total."
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-input v-if="cassetteOpDialog.op_type === 'recount'"
|
||||||
|
v-model.number="cassetteOpDialog.count"
|
||||||
|
type="number" min="0" step="1" dense outlined
|
||||||
|
label="Notes counted"
|
||||||
|
hint="What you physically counted in the bay, right now."
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-input v-if="cassetteOpDialog.op_type === 'set_denomination'"
|
||||||
|
v-model.number="cassetteOpDialog.denomination"
|
||||||
|
type="number" min="1" step="1" dense outlined
|
||||||
|
label="Denomination"
|
||||||
|
:suffix="machineDetail.machine
|
||||||
|
? (machineDetail.machine.fiat_code || '') : ''"
|
||||||
|
hint="What is now loaded in that bay. The machine can't
|
||||||
|
know this — only you can."
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-banner v-if="cassetteOpDialog.op_type === 'empty'"
|
||||||
|
class="bg-grey-3 text-grey-9">
|
||||||
|
The bay is now empty. Nothing else to enter.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner v-if="cassetteOpDialog.error"
|
||||||
|
class="bg-red-1 text-grey-9 q-mt-md">
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="warning" color="negative"></q-icon>
|
||||||
|
</template>
|
||||||
|
<span v-text="cassetteOpDialog.error"></span>
|
||||||
</q-banner>
|
</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.position">
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>
|
|
||||||
<b v-text="'Bay ' + row.position"></b>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<q-item-label caption>
|
|
||||||
<b v-text="row.denomination + ' ' +
|
|
||||||
(machineDetail.machine.fiat_code || '')"></b>
|
|
||||||
· count
|
|
||||||
<b v-text="row.count"></b>
|
|
||||||
</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||||
<q-btn color="primary"
|
<q-btn color="primary"
|
||||||
label="Publish to ATM"
|
label="Record + publish"
|
||||||
:loading="machineDetail.cassettesPublishing"
|
:disable="!cassetteOpIsComplete"
|
||||||
@click="submitCassettePublish"></q-btn>
|
:loading="cassetteOpDialog.saving"
|
||||||
|
@click="submitCassetteOp"></q-btn>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue