spirekeeper/templates/spirekeeper/index.html
Padreug cdb1fe71cc feat(ui): settle off-machine, glossary links, alerts pubkey
Settle action on the cash_owed/partial_pending worklist buckets and the
settlements table; the worklist's error cell links the raw code to the
glossary entry; the platform settings dialog gains the alerts pubkey.
2026-10-10 22:26:45 +02:00

1855 lines
85 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('spirekeeper/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md" id="dcaAdmin">
<div class="col-12">
<!-- Header bar -->
<div class="row items-center q-mb-md">
<div class="col">
<h4 class="q-my-none">Satoshi Machine — Operator</h4>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Manage your bitSpire fleet, liquidity providers, and commission distribution.
<a href="/spirekeeper/static/docs/operator-guide.html" target="_blank" rel="noopener"
:style="{marginLeft: '8px'}">Operator guide</a> ·
<a href="/spirekeeper/static/docs/errors.html" target="_blank" rel="noopener">Error glossary</a>
</p>
</div>
<div class="col-auto">
<q-btn
flat dense round icon="refresh" color="grey"
@click="refreshAll" :loading="refreshing">
<q-tooltip>Refresh all data</q-tooltip>
</q-btn>
</div>
</div>
<!-- Platform fee banner (read-only for operators; editable inline if super) -->
<q-banner
v-if="superConfig"
class="q-mb-md"
:class="superAnyFee > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
<template v-slot:avatar>
<q-icon name="account_balance" :color="superAnyFee > 0 ? 'blue' : 'grey'"></q-icon>
</template>
<span :style="{fontWeight: 500}">
LNbits platform fee:
<span :style="{color: '#1976d2'}">cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }%</span>
·
<span :style="{color: '#1976d2'}">cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }%</span>
of each transaction's principal.
</span>
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
Operator's per-machine fee rides on top of these.
</span>
<template v-slot:action>
<q-btn v-if="g?.user?.super_user"
flat dense color="blue" icon="edit"
label="Edit"
@click="openSuperFeeDialog">
<q-tooltip>Super-only: set platform fee + destination wallet</q-tooltip>
</q-btn>
</template>
</q-banner>
<!-- Main tab strip -->
<q-card flat bordered>
<q-tabs
v-model="activeTab"
dense
align="left"
class="text-grey-7"
active-color="primary"
indicator-color="primary"
narrow-indicator>
<q-tab name="fleet" icon="precision_manufacturing" label="Fleet"></q-tab>
<q-tab name="clients" icon="group" label="Clients"></q-tab>
<q-tab name="deposits" icon="receipt_long" label="Deposits"></q-tab>
<q-tab name="commission" icon="call_split" label="Commission"></q-tab>
<q-tab name="worklist" icon="warning" label="Worklist">
<q-badge
v-if="worklistCount > 0"
color="red" floating>${ worklistCount }
</q-badge>
</q-tab>
<q-tab name="reports" icon="download" label="Reports"></q-tab>
</q-tabs>
<q-separator ></q-separator>
<q-tab-panels v-model="activeTab" animated>
<!-- ============================================================= -->
<!-- FLEET TAB -->
<!-- ============================================================= -->
<q-tab-panel name="fleet">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Your machines</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Each ATM is paired with one dedicated wallet. Inbound payments to
that wallet trigger automatic distribution.
</p>
</div>
<div class="col-auto">
<q-btn
color="primary" icon="add"
label="Add machine"
@click="openAddMachineDialog"></q-btn>
</div>
</div>
<q-banner v-if="!machines.length" class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
You haven't registered any machines yet. Click <b>Add machine</b> to
register a bitSpire ATM by its Nostr npub.
</q-banner>
<q-table
v-else
dense flat
:rows="machines"
row-key="id"
:columns="machinesTable.columns"
:pagination="machinesTable.pagination"
:rows-per-page-options="[10, 25, 50]">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status">
<q-icon
:name="props.row.is_active ? 'check_circle' : 'pause_circle'"
:color="props.row.is_active ? 'green' : 'grey'"
size="sm">
<q-tooltip>${ props.row.is_active ? 'Active' : 'Paused' }</q-tooltip>
</q-icon>
</q-td>
<q-td key="name">
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
<div :style="{fontSize: '0.8em', opacity: 0.6}"
v-text="props.row.location || 'No location set'"></div>
<q-chip v-if="props.row.paired_at"
dense size="sm" color="green-2" text-color="green-9"
icon="link" :style="{marginTop: '2px'}">
paired
<q-tooltip>Bunker key minted; paired ${ new Date(props.row.paired_at).toLocaleString() }</q-tooltip>
</q-chip>
<q-chip v-else
dense size="sm" color="grey-3" text-color="grey-8"
icon="link_off" :style="{marginTop: '2px'}">
not paired
</q-chip>
</q-td>
<q-td key="machine_npub">
<code :style="{fontSize: '0.85em'}"
v-text="shortNpub(props.row.machine_npub)"></code>
<q-btn flat dense round size="xs" icon="content_copy"
@click="copy(props.row.machine_npub)">
<q-tooltip>Copy full npub</q-tooltip>
</q-btn>
</q-td>
<q-td key="wallet_id">
<code :style="{fontSize: '0.85em'}"
v-text="shortId(props.row.wallet_id)"></code>
</q-td>
<q-td key="fiat_code" v-text="props.row.fiat_code"></q-td>
<q-td key="actions" auto-width>
<q-btn flat dense round size="sm" icon="visibility"
color="primary"
@click="viewMachine(props.row)">
<q-tooltip>View settlements</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="edit"
color="grey"
@click="openEditMachineDialog(props.row)">
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="qr_code_2"
color="teal"
@click="openPairDialog(props.row)">
<q-tooltip>${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' }</q-tooltip>
</q-btn>
<q-btn v-if="props.row.paired_at"
flat dense round size="sm" icon="link_off"
color="orange-8"
@click="confirmRevokeMachine(props.row)">
<q-tooltip>Revoke spire access</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="delete"
color="red-7"
@click="confirmDeleteMachine(props.row)">
<q-tooltip>Delete</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
<!-- ============================================================= -->
<!-- PLACEHOLDERS for tabs that land in P9b–P9g -->
<!-- ============================================================= -->
<q-tab-panel name="clients">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Liquidity providers</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
LPs receive proportional DCA distributions from your machines.
Balances reflect deposits less the sats they've been paid.
</p>
</div>
<div class="col-auto">
<q-btn color="primary" icon="person_add"
label="Register LP"
:disable="!machines.length"
@click="openAddClientDialog"></q-btn>
</div>
</div>
<q-banner v-if="!machines.length" class="bg-orange-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="warning" color="orange"></q-icon>
</template>
Register at least one machine before adding LPs — an LP is scoped
to a specific machine.
</q-banner>
<q-banner v-else-if="!clients.length" class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
No LPs yet. Use <b>Register LP</b> to add one at any of your machines.
</q-banner>
<q-table v-else
dense flat
:rows="clients"
row-key="id"
:columns="clientsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="clientsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="machine">
<span :style="{fontWeight: 500}"
v-text="machineNameById(props.row.machine_id)"></span>
</q-td>
<q-td key="username">
<span v-text="props.row.username || shortId(props.row.user_id)"></span>
</q-td>
<q-td key="onboarded">
<q-icon v-if="props.row.lp_onboarded"
name="check_circle" color="green" size="sm">
<q-tooltip>
LP has onboarded via satmachineclient.
Deposits and DCA distributions can proceed.
</q-tooltip>
</q-icon>
<q-icon v-else name="pending" color="deep-orange" size="sm">
<q-tooltip>
LP hasn't installed/opened satmachineclient yet.
Deposits will be refused until they register and
select a DCA wallet.
</q-tooltip>
</q-icon>
</q-td>
<q-td key="remaining_balance" class="text-right">
<span v-if="clientBalances[props.row.id]"
:style="{color: clientBalances[props.row.id].remaining_balance > 0 ? '#2e7d32' : '#9e9e9e', fontWeight: 500}"
v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
<span v-else :style="{opacity: 0.5}">…</span>
</q-td>
<q-td key="status">
<q-badge
:color="props.row.status === 'active' ? 'green' : 'grey'"
:label="props.row.status"></q-badge>
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" icon="more_vert">
<q-list dense>
<q-item clickable v-close-popup
@click="openEditClientDialog(props.row)">
<q-item-section avatar><q-icon name="edit"></q-icon></q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item clickable v-close-popup
@click="openSettleBalanceDialog(props.row)">
<q-item-section avatar>
<q-icon name="payments" color="primary"></q-icon>
</q-item-section>
<q-item-section>Settle balance…</q-item-section>
</q-item>
<q-item clickable v-close-popup
@click="confirmDeleteClient(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="red-7"></q-icon>
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
<q-tab-panel name="deposits">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Deposits</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Record fiat handed in by LPs. Confirmed deposits increase the
LP's balance and feed proportional DCA distribution.
</p>
</div>
<div class="col-auto">
<q-btn color="primary" icon="add"
label="Record deposit"
:disable="!clients.length"
@click="openAddDepositDialog"></q-btn>
</div>
</div>
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-12 col-md-3">
<q-select v-model="depositsFilter.status"
:options="[
{label: 'All statuses', value: null},
{label: 'Pending', value: 'pending'},
{label: 'Confirmed', value: 'confirmed'},
{label: 'Rejected', value: 'rejected'}]"
label="Status" emit-value map-options dense outlined></q-select>
</div>
<div class="col-12 col-md-4">
<q-select v-model="depositsFilter.client_id"
:options="depositClientOptions"
label="LP" emit-value map-options dense outlined clearable></q-select>
</div>
</div>
<q-banner v-if="!clients.length" class="bg-orange-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="warning" color="orange"></q-icon>
</template>
Register at least one LP before recording deposits.
</q-banner>
<q-banner v-else-if="!filteredDeposits.length && !deposits.length"
class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
No deposits yet. Use <b>Record deposit</b> to log a new one.
</q-banner>
<q-banner v-else-if="!filteredDeposits.length"
class="bg-grey-2 text-grey-9">
No deposits match the current filters.
</q-banner>
<q-table v-else
dense flat
:rows="filteredDeposits"
row-key="id"
:columns="depositsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="depositsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status">
<q-badge :color="depositStatusColor(props.row.status)"
:label="props.row.status"></q-badge>
</q-td>
<q-td key="client">
<span v-text="clientUsernameById(props.row.client_id)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}"
v-text="machineNameById(props.row.machine_id)"></div>
</q-td>
<q-td key="amount" class="text-right">
<span :style="{fontWeight: 500}"
v-text="formatFiat(props.row.amount, props.row.currency)"></span>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="confirmed_at">
<span v-if="props.row.confirmed_at"
:style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.confirmed_at)"></span>
<span v-else :style="{opacity: 0.4}">—</span>
</q-td>
<q-td key="notes">
<span v-if="props.row.notes"
:style="{fontSize: '0.85em'}"
v-text="props.row.notes"></span>
<span v-else :style="{opacity: 0.4}">—</span>
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" icon="more_vert">
<q-list dense>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="confirmDepositStatus(props.row, 'confirmed')">
<q-item-section avatar>
<q-icon name="check_circle" color="green"></q-icon>
</q-item-section>
<q-item-section>Confirm</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="openRejectDepositDialog(props.row)">
<q-item-section avatar>
<q-icon name="cancel" color="red"></q-icon>
</q-item-section>
<q-item-section>Reject…</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="openEditDepositDialog(props.row)">
<q-item-section avatar>
<q-icon name="edit"></q-icon>
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="confirmDeleteDeposit(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="red-7"></q-icon>
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
<q-tab-panel name="commission">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Commission splits</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
After the LNbits platform fee is taken, the remainder is
distributed across the wallets you configure here. Per-machine
overrides take precedence over your default rules.
</p>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-6">
<q-select v-model="commissionScope"
:options="commissionScopeOptions"
label="Scope being edited"
emit-value map-options
dense outlined
@update:model-value="loadCommissionSplits"></q-select>
<div class="text-caption q-mt-xs" :style="{opacity: 0.7}">
<span v-if="commissionScope === null">
Default ruleset — applies to every machine without an
explicit override.
</span>
<span v-else>
Per-machine override for
<b v-text="machineNameById(commissionScope)"></b>.
Empty/cleared rows fall back to the default.
</span>
</div>
</div>
</div>
<q-card flat bordered>
<q-card-section>
<div class="row items-center q-mb-sm">
<div class="col">
<span :style="{fontWeight: 500}">Legs</span>
<span class="q-ml-md text-caption">
Sum:
<span :style="{
color: commissionSumValid ? '#2e7d32' : '#c62828',
fontWeight: 500
}"
v-text="(commissionSum * 100).toFixed(2) + '%'"></span>
<q-icon v-if="commissionSumValid"
name="check_circle" color="green" size="xs"
class="q-ml-xs"></q-icon>
<q-icon v-else
name="error" color="red" size="xs"
class="q-ml-xs">
<q-tooltip>Must sum to 100% before saving</q-tooltip>
</q-icon>
</span>
</div>
<div class="col-auto">
<q-btn flat dense color="primary" icon="add"
label="Add leg"
@click="addCommissionLeg"></q-btn>
</div>
</div>
<q-banner v-if="!commissionLegs.length"
class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
<span v-if="commissionScope === null">
No default rules. Without a default, all operator
commission stays in the machine wallet (audit visible).
</span>
<span v-else>
No override for this machine. The default ruleset applies.
</span>
</q-banner>
<div v-for="(leg, idx) in commissionLegs" :key="idx"
class="q-mb-md q-pa-sm"
:style="{border: '1px solid rgba(255,255,255,0.08)', borderRadius: '4px'}">
<div class="row q-col-gutter-sm items-center q-mb-sm">
<div class="col">
<q-btn-toggle v-model="leg.targetKind"
:options="[
{label: 'My wallet', value: 'wallet'},
{label: 'Lightning address / LNURL / invoice key', value: 'external'}
]"
no-caps dense flat
toggle-color="primary"
color="grey-8"></q-btn-toggle>
</div>
<div class="col-auto">
<q-btn flat dense round icon="delete" color="red-7"
@click="commissionLegs.splice(idx, 1)">
<q-tooltip>Remove leg</q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-col-gutter-sm items-start">
<div class="col-12 col-md-6">
<q-select v-if="leg.targetKind === 'wallet'"
v-model="leg.target"
:options="walletOptions"
label="Wallet (one of yours)"
emit-value map-options dense outlined></q-select>
<q-input v-else
v-model.trim="leg.target"
label="LN address, LNURL, or invoice key"
hint="user@domain · LNURL1... · or an LP's invoice key"
dense outlined></q-input>
</div>
<div class="col-7 col-md-3">
<q-input v-model="leg.label"
label="Label (employee, maintenance, ...)"
dense outlined></q-input>
</div>
<div class="col-5 col-md-3">
<q-input v-model.number="leg.fraction"
label="% (0..1)"
type="number" step="0.01" min="0" max="1"
dense outlined>
<template v-slot:append>
<span :style="{fontSize: '0.75em', opacity: 0.6}"
v-text="((leg.fraction || 0) * 100).toFixed(1) + '%'"></span>
</template>
</q-input>
</div>
</div>
</div>
<q-banner v-if="commissionPreview" class="bg-grey-2 text-grey-9 q-mt-md">
<template v-slot:avatar>
<q-icon name="visibility" color="grey"></q-icon>
</template>
Preview against
<b v-text="formatSats(commissionPreviewInput)"></b>
sats operator commission →
<span v-for="(prev, idx) in commissionPreview" :key="idx"
class="q-mr-md">
<span :style="{opacity: 0.7}"
v-text="prev.label || ('leg ' + (idx + 1))"></span>:
<b v-text="formatSats(prev.sats)"></b>
</span>
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-if="commissionScope !== null && commissionLegs.length"
flat color="red"
label="Remove override"
@click="confirmDeleteCommissionOverride"></q-btn>
<q-btn flat label="Reload"
:disable="commissionSaving"
@click="loadCommissionSplits"></q-btn>
<q-btn color="primary"
label="Save"
:disable="!commissionSumValid"
:loading="commissionSaving"
@click="saveCommissionSplits"></q-btn>
</q-card-actions>
</q-card>
</q-tab-panel>
<q-tab-panel name="worklist">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Worklist</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Settlements that didn't process cleanly. Errored ones need
retry; stuck ones may need force-reset (processor crashed
mid-flight).
</p>
</div>
<div class="col-auto row items-center q-gutter-sm">
<q-input v-model.number="worklistThreshold"
label="Threshold (min)" dense outlined
:style="{width: '120px'}"
type="number" min="1"></q-input>
<q-btn flat dense color="primary" icon="refresh"
:loading="worklistLoading"
@click="loadWorklist"></q-btn>
</div>
</div>
<q-banner v-if="worklist.totalCount === 0"
class="bg-green-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="check_circle" color="green"></q-icon>
</template>
All clear — no errored or stuck settlements.
</q-banner>
<div v-for="bucket in worklistBuckets" :key="bucket.key"
v-show="bucket.rows.length"
class="q-mb-lg">
<div class="row items-center q-mb-sm">
<q-icon :name="bucket.icon" :color="bucket.color" size="sm"
class="q-mr-sm"></q-icon>
<span :style="{fontWeight: 500}" v-text="bucket.label"></span>
<q-chip dense
:color="bucket.color" text-color="white"
class="q-ml-sm"
v-text="bucket.rows.length"></q-chip>
</div>
<q-table dense flat
:rows="bucket.rows"
row-key="id"
:columns="worklistTable.columns"
hide-pagination
:pagination="{rowsPerPage: 0}">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="machine">
<span v-text="machineNameById(props.row.machine_id)"></span>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="error_message">
<span :style="{fontSize: '0.85em', opacity: 0.8}"
v-text="props.row.error_message || '—'"></span>
<a v-if="props.row.dispense_error_code"
:href="errorGlossaryHref(props.row)" target="_blank" rel="noopener"
:style="{fontSize: '0.8em', marginLeft: '6px', whiteSpace: 'nowrap'}">
<code v-text="props.row.dispense_raw_code || props.row.dispense_error_code"></code>
<q-icon name="help_outline" size="xs"></q-icon>
<q-tooltip>What this code means and what to do</q-tooltip>
</a>
</q-td>
<q-td key="actions" auto-width>
<q-btn flat dense size="sm" icon="open_in_new"
color="primary"
@click="viewMachineFromWorklist(props.row)">
<q-tooltip>Open machine detail</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'cash_owed' || bucket.key === 'partial_pending'"
flat dense size="sm" icon="handshake"
color="positive"
@click="openSettleCashOwed(props.row)">
<q-tooltip>Settle off-machine — you paid the customer by hand</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'partial_pending'"
flat dense size="sm" icon="call_split"
color="deep-orange"
@click="openPartialDispense(props.row)">
<q-tooltip>Record the resolution (pre-filled from the machine's report)</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'errored'"
flat dense size="sm" icon="restart_alt"
color="primary"
@click="confirmRetryFromWorklist(props.row)">
<q-tooltip>Retry distribution</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'stuck_pending' || bucket.key === 'stuck_processing'"
flat dense size="sm" icon="local_fire_department"
color="red"
@click="confirmForceResetFromWorklist(props.row)">
<q-tooltip>Force-reset (stuck)</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-tab-panel>
<q-tab-panel name="reports">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Reports</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Client-side CSV exports of the data currently loaded in the
dashboard. For larger date ranges or server-side filters,
use the LNbits API directly.
</p>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Machines</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="machines.length"></span> rows
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="machines.csv"
@click="downloadMachinesCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Clients (LPs)</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="clients.length"></span> rows, balances included
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="clients.csv"
@click="downloadClientsCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Deposits</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="deposits.length"></span> rows
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="deposits.csv"
@click="downloadDepositsCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Payments (legs)</div>
<div class="text-caption" :style="{opacity: 0.7}">
Distribution audit (dca / super_fee / operator_split / etc)
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="payments.csv"
:loading="reportsBusy"
@click="downloadPaymentsCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</q-card>
<!-- =============================================================== -->
<!-- ADD MACHINE DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="addMachineDialog.show" persistent>
<q-card :style="{width: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Add bitSpire machine</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section class="scroll" :style="{maxHeight: '65vh'}">
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Register an ATM by its Nostr public key. Choose the LNbits wallet that
will receive cash-out payments from this machine — settlements there
trigger the automatic distribution chain.
</p>
<q-input
v-model="addMachineDialog.data.name"
label="Machine name"
hint="Operator-friendly label (e.g. ATM-Antigua-1)"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model="addMachineDialog.data.location"
label="Location (optional)"
hint="Physical address or city; shown on operator dashboard"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model="addMachineDialog.data.machine_npub"
label="Machine npub — DEVELOPMENT ONLY (blank = normal bunker pairing)"
hint="⚠ Leave blank for normal operation: the bunker mints this machine's key when you pair it (no nsec ever lands on the machine). Only fill this to register a machine that holds its OWN signing key — development / self-signing. Hex or npub1…"
color="orange"
class="q-mb-md"
dense outlined
:rules="[
v => !v || v.length >= 32 || 'Looks too short — use a full hex/npub, or leave blank'
]"></q-input>
<q-select
v-model="addMachineDialog.data.wallet_id"
:options="walletOptions"
label="Wallet (receives ATM payments)"
emit-value map-options
class="q-mb-md"
dense outlined
:rules="[v => !!v || 'Pick a wallet']"></q-select>
<q-input
v-model="addMachineDialog.data.fiat_code"
label="Fiat code"
hint="Currency the ATM dispenses (GTQ / USD / MXN / etc.)"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee (decimal fraction, 0-0.15)"
hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee (decimal fraction, 0-0.15)"
hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
color="primary" label="Add machine"
:loading="addMachineDialog.saving"
@click="submitAddMachine"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- PAIR SPIRE DIALOG — mint bunker key + one-shot seed URL (S0/#9) -->
<!-- =============================================================== -->
<q-dialog v-model="pairDialog.show" persistent>
<q-card :style="{width: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Pair spire</div>
<q-space></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<!-- Step 1 — configure + generate -->
<template v-if="!pairDialog.result">
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Mints a dedicated signing key for
<b v-text="(pairDialog.machine && pairDialog.machine.name) || 'this spire'"></b>
inside the operator bunker and issues a one-shot seed URL. The
spire's key never touches its disk; its cash-outs route to this
machine's wallet. Re-pairing issues a fresh seed.
</p>
<q-input
v-model="pairDialog.relays"
label="Relay(s) for the spire's events"
hint="Pre-filled with the relay this instance's nostr transport listens on. Leave as-is unless the spire must use a different relay. One per line; blank uses the default."
type="textarea" autogrow
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="pairDialog.durationHours"
label="Token lifetime in hours (optional)"
hint="Blank = non-expiring. Set e.g. 720 (30 days) to force periodic re-pairing."
type="number" min="1"
class="q-mb-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
color="primary" label="Generate seed URL" icon="vpn_key"
:loading="pairDialog.saving"
@click="submitPair"></q-btn>
</q-card-actions>
</template>
<!-- Step 2 — show the seed URL -->
<template v-else>
<q-card-section>
<q-banner dense rounded class="bg-green-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="check_circle" color="green"></q-icon>
</template>
Paired. Scan this on the spire at first boot, or paste the seed URL
into <code>provision-atm</code>. Shown once — copy it now.
</q-banner>
<div class="column items-center q-mb-md">
<div class="cursor-pointer" :style="{width: '280px', maxWidth: '90vw'}"
@click="openQrZoom(pairDialog.result.seed_url)">
<lnbits-qrcode
:value="pairDialog.result.seed_url"
:max-width="280"></lnbits-qrcode>
</div>
<q-btn flat dense color="primary" icon="zoom_out_map"
label="Enlarge for scanning" size="sm" class="q-mt-sm"
@click="openQrZoom(pairDialog.result.seed_url)"></q-btn>
</div>
<q-input
v-model="pairDialog.result.seed_url"
label="Seed URL"
type="textarea" autogrow readonly
class="q-mb-sm"
dense outlined>
<template v-slot:append>
<q-btn flat dense round icon="content_copy"
@click="copy(pairDialog.result.seed_url)">
<q-tooltip>Copy seed URL</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="text-caption" :style="{opacity: 0.7}">
Spire identity:
<code :style="{fontSize: '0.85em'}"
v-text="shortNpub(pairDialog.result.spire_npub)"></code>
<q-btn flat dense round size="xs" icon="content_copy"
@click="copy(pairDialog.result.spire_npub)">
<q-tooltip>Copy npub</q-tooltip>
</q-btn>
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Done" color="primary" v-close-popup></q-btn>
</q-card-actions>
</template>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- QR ZOOM DIALOG — fullscreen QR to maximise scan visibility -->
<!-- =============================================================== -->
<q-dialog v-model="qrZoom.show" maximized>
<q-card class="bg-white text-grey-9 column no-wrap">
<q-bar class="bg-white">
<q-space></q-space>
<q-btn dense flat icon="close" color="grey-9" v-close-popup>
<q-tooltip>Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section
class="col column items-center justify-center q-pt-none">
<!-- lnbits-qrcode renders width:100% of its parent capped by the
max-width prop (NOT an `options` prop). Give it an explicit
full-width wrapper so it actually fills the screen. -->
<div :style="{width: qrZoom.size + 'px', maxWidth: '96vw'}">
<lnbits-qrcode
:value="qrZoom.value"
:max-width="qrZoom.size"
:show-buttons="false"></lnbits-qrcode>
</div>
<div class="text-caption text-center q-mt-md" :style="{opacity: 0.7}">
Point the spire's camera here — tap Close when scanned.
</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
<!-- =============================================================== -->
<q-dialog v-model="machineDetail.show" maximized
transition-show="slide-up" transition-hide="slide-down">
<q-card v-if="machineDetail.machine">
<q-bar class="bg-primary text-white">
<div class="text-h6 q-mr-md"
v-text="machineDetail.machine.name || 'Unnamed machine'"></div>
<q-chip dense color="white" text-color="primary"
v-text="machineDetail.machine.fiat_code"></q-chip>
<q-space ></q-space>
<q-btn flat dense round icon="refresh"
@click="reloadMachineDetail"
:loading="machineDetail.loading">
<q-tooltip>Reload</q-tooltip>
</q-btn>
<q-btn flat dense round icon="close" v-close-popup>
<q-tooltip>Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-4">
<div class="text-caption" :style="{opacity: 0.6}">npub</div>
<code :style="{fontSize: '0.85em', wordBreak: 'break-all'}"
v-text="machineDetail.machine.machine_npub"></code>
</div>
<div class="col-12 col-md-3">
<div class="text-caption" :style="{opacity: 0.6}">Wallet</div>
<code :style="{fontSize: '0.85em'}"
v-text="machineDetail.machine.wallet_id"></code>
</div>
<div class="col-6 col-md-2">
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
<span v-text="machineDetail.machine.location || '—'"></span>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
<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}">
Every bitSpire transaction lands here. Click a row's menu for
retry / partial-dispense / notes.
</p>
</div>
</div>
<q-banner v-if="!machineDetail.settlements.length"
class="bg-grey-2 text-grey-9">
No settlements yet. They'll appear when bitSpire pays this machine's
wallet.
</q-banner>
<q-table v-else
dense flat
:rows="machineDetail.settlements"
row-key="id"
:columns="settlementsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="settlementsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status">
<q-badge :color="settlementStatusColor(props.row.status)"
:label="props.row.status"></q-badge>
</q-td>
<q-td key="tx_type">
<q-chip dense square size="sm"
:color="txTypeChip(props.row.tx_type).color"
text-color="white"
:icon="txTypeChip(props.row.tx_type).icon">
<span v-text="txTypeChip(props.row.tx_type).label"></span>
<q-tooltip>
<span v-text="txTypeChip(props.row.tx_type).tooltip"></span>
</q-tooltip>
</q-chip>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="principal_sats" class="text-right">
<span v-text="formatSats(props.row.principal_sats)"></span>
</q-td>
<q-td key="fee_sats" class="text-right">
<span v-text="formatSats(props.row.fee_sats)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}">
super
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
/ op
<span v-text="formatSats(props.row.operator_fee_sats)"></span>
</div>
</q-td>
<q-td key="fiat_amount" class="text-right">
<span v-text="formatFiat(props.row.fiat_amount, props.row.fiat_code)"></span>
</q-td>
<q-td key="payment_hash">
<code :style="{fontSize: '0.8em'}"
v-text="shortId(props.row.payment_hash)"></code>
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" label="" icon="more_vert">
<q-list dense>
<q-item clickable v-close-popup
@click="openSettlementNote(props.row)">
<q-item-section avatar>
<q-icon name="edit_note"></q-icon>
</q-item-section>
<q-item-section>Add note</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'errored'"
clickable v-close-popup
@click="confirmRetrySettlement(props.row)">
<q-item-section avatar>
<q-icon name="restart_alt" color="primary"></q-icon>
</q-item-section>
<q-item-section>Retry distribution</q-item-section>
</q-item>
<q-item
v-if="['cash_owed','partial_pending'].includes(props.row.status)"
clickable v-close-popup
@click="openSettleCashOwed(props.row)">
<q-item-section avatar>
<q-icon name="handshake" color="positive"></q-icon>
</q-item-section>
<q-item-section>Settle off-machine…</q-item-section>
</q-item>
<q-item
v-if="['pending','errored'].includes(props.row.status)"
clickable v-close-popup
@click="openPartialDispense(props.row)">
<q-item-section avatar>
<q-icon name="precision_manufacturing" color="warning"></q-icon>
</q-item-section>
<q-item-section>Partial dispense…</q-item-section>
</q-item>
<q-item
v-if="['pending','processing'].includes(props.row.status)"
clickable v-close-popup
@click="confirmForceReset(props.row)">
<q-item-section avatar>
<q-icon name="local_fire_department" color="red"></q-icon>
</q-item-section>
<q-item-section>Force-reset (stuck)…</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
<q-tr v-if="props.row.notes" :props="props" no-hover>
<q-td colspan="100%" class="bg-grey-2">
<pre :style="{whiteSpace: 'pre-wrap', fontSize: '0.8em', margin: 0}"
v-text="props.row.notes"></pre>
</q-td>
</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}">
The machine owns these counts — it holds the notes. You
record what you <i>did</i> 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).
</p>
</div>
<div class="col-auto">
<q-btn flat dense icon="refresh" label="Refresh"
:loading="machineDetail.cassettesLoading"
@click="loadMachineCassettes">
<q-tooltip>Re-read the machine's latest report</q-tooltip>
</q-btn>
<q-btn color="primary" icon="add" label="Record operation"
:disable="!machineDetail.cassettes.length"
@click="openCassetteOpDialog()"></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.machine
&& machineDetail.machine.cash_out_held_since"
class="bg-red-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="block" color="negative"></q-icon>
</template>
<b>Cash-out is held.</b>
The machine latched cash-out off after a terminal dispenser fault
(<span v-text="machineDetail.machine.cash_out_held_code || 'fault'"></span>
at <span v-text="formatTime(machineDetail.machine.cash_out_held_since)"></span>):
<span v-text="machineDetail.machine.cash_out_held_reason"></span>.
Clear the transport path, then either record a <b>Recount</b>
(which also fixes the count) or release it here.
<template v-slot:action>
<q-btn flat color="negative" label="Resume cash-out"
@click="confirmResumeCashOut(machineDetail.machine)"></q-btn>
</template>
</q-banner>
<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"
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 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.cassettes.length"
dense flat
:rows="machineDetail.cassettes"
row-key="position"
:columns="cassettesTable.columns"
:pagination="cassettesTable.pagination"
hide-pagination>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="position" class="text-right">
<b v-text="'Bay ' + props.row.position"></b>
</q-td>
<q-td key="denomination" class="text-right">
<span v-text="props.row.denomination"></span>
<span :style="{opacity: 0.6}"
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
</q-td>
<q-td key="count" class="text-right">
<b v-text="props.row.count"></b>
<span :style="{opacity: 0.6}"> notes</span>
</q-td>
<q-td key="state_at">
<span :style="{fontSize: '0.85em', opacity: 0.7}"
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-tr>
</template>
</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-panels>
</q-card-section>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- RECORD CASSETTE OPERATION DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="cassetteOpDialog.show" persistent>
<q-card :style="{width: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Record cassette operation</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-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
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.
</q-banner>
<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-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
label="Record + publish"
:disable="!cassetteOpIsComplete"
:loading="cassetteOpDialog.saving"
@click="submitCassetteOp"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- PARTIAL-DISPENSE DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="partialDispenseDialog.show" persistent>
<q-card :style="{width: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Apply partial dispense</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section v-if="partialDispenseDialog.settlement">
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
Original gross:
<b v-text="formatSats(partialDispenseDialog.settlement.wire_sats)"></b>.
Provide what was actually dispensed. Sat amounts will scale linearly,
the commission split will recompute, and distribution will re-run.
</q-banner>
<q-tabs v-model="partialDispenseDialog.mode" dense
active-color="primary" indicator-color="primary"
align="justify">
<q-tab name="fraction" label="By fraction (0..1)"></q-tab>
<q-tab name="sats" label="By exact sats"></q-tab>
</q-tabs>
<q-tab-panels v-model="partialDispenseDialog.mode" animated>
<q-tab-panel name="fraction" class="q-pa-sm">
<q-input v-model.number="partialDispenseDialog.dispensed_fraction"
label="Dispensed fraction"
hint="e.g. 0.6 means 60% of the original tx was dispensed"
type="number" step="0.01" min="0" max="1"
dense outlined></q-input>
</q-tab-panel>
<q-tab-panel name="sats" class="q-pa-sm">
<q-input v-model.number="partialDispenseDialog.dispensed_sats"
label="Dispensed sats"
hint="Exact sat amount actually dispensed (≤ original gross)"
type="number" step="1" min="0"
:max="partialDispenseDialog.settlement.wire_sats"
dense outlined></q-input>
</q-tab-panel>
</q-tab-panels>
<q-input v-model="partialDispenseDialog.notes"
label="Reason (recorded in audit memo)"
type="textarea" autogrow
class="q-mt-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="warning"
label="Apply partial dispense"
:loading="partialDispenseDialog.saving"
@click="submitPartialDispense"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD-NOTE DIALOG -->
<!-- =============================================================== -->
<!-- ADR-005 §6: settle an owed-cash settlement off-machine -->
<q-dialog v-model="settleDialog.show" persistent>
<q-card :style="{width: '460px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Settle off-machine</div>
<q-space></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section v-if="settleDialog.settlement">
<p class="q-mb-sm">
The customer paid
<strong v-text="formatFiat(settleDialog.settlement.fiat_amount, settleDialog.settlement.fiat_code)"></strong>
and the machine reports
<strong v-text="settleDialog.settlement.status === 'cash_owed' ? 'nothing dispensed' : 'a partial dispense'"></strong>.
</p>
<p class="text-caption q-mb-md" :style="{opacity: 0.8}">
Use this when you have made the customer whole <em>outside</em> the machine.
The settlement distributes at the full amount and the machine marks its own
record remediated. Nothing is dispensed. If you dispensed the shortfall at the
machine instead, do nothing — it reports that itself.
</p>
<q-input v-model="settleDialog.note" type="textarea" autogrow outlined dense
label="How was the customer paid? (required)"
hint="e.g. handed 40 EUR to the customer in person, 2026-10-09"
maxlength="500" counter></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="positive" label="Settle at full amount"
:disable="!settleDialog.note.trim()" :loading="settleDialog.saving"
@click="submitSettleCashOwed"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="noteDialog.show" persistent>
<q-card :style="{width: '420px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Add note to settlement</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-sm" :style="{opacity: 0.7}">
Notes are append-only and timestamped. Use for reconciliation context,
off-LN refund records, dispute narrative, etc.
</p>
<q-input v-model="noteDialog.note"
label="Note"
type="textarea" autogrow
:rules="[v => (v && v.trim().length > 0) || 'Note cannot be empty']"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Save note"
:loading="noteDialog.saving"
@click="submitNote"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- SUPER-FEE EDIT DIALOG (super-only) -->
<!-- =============================================================== -->
<q-dialog v-model="superFeeDialog.show" persistent>
<q-card :style="{width: '460px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Platform fee (super-only)</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Charged on every transaction's principal across the LNbits
instance. Independent per direction. Each direction's total
(super + operator) is capped at 15%. Operators see these as a
read-only banner. Wallet ID is where the collected fee lands;
typically a wallet you (the super) own.
</p>
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
label="Cash-in fee (decimal fraction, 0-0.15)"
hint="0.03 = 3% of principal on cash-in transactions"
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
label="Cash-out fee (decimal fraction, 0-0.15)"
hint="0.03 = 3% of principal on cash-out transactions"
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="superFeeDialog.data.super_fee_wallet_id"
label="Super fee destination wallet_id"
hint="LNbits wallet that collects the platform fee"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="superFeeDialog.data.max_cash_in_sats"
label="Max cash-in per transaction (sats — blank = no cap)"
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
type="number" step="1" min="0"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="superFeeDialog.data.alerts_pubkey"
label="Alerts pubkey (hex or npub — blank = each operator's own account key)"
hint="Where owed-cash alerts are delivered as Nostr DMs (NIP-17). Blank sends each operator a note to self on their LNbits account identity."
class="q-mb-md" dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Save"
:loading="superFeeDialog.saving"
@click="submitSuperFee"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD / EDIT DEPOSIT DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="depositDialog.show" persistent>
<q-card :style="{width: '460px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6"
v-text="depositDialog.mode === 'add' ? 'Record deposit' : 'Edit deposit'"></div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<q-select v-if="depositDialog.mode === 'add'"
v-model="depositDialog.data.client_id"
:options="depositClientOptions"
label="Liquidity provider"
emit-value map-options
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Pick an LP']"></q-select>
<q-banner v-if="depositDialog.mode === 'add' && selectedDepositClient && !selectedDepositClient.lp_onboarded"
class="bg-deep-orange-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="pending" color="deep-orange"></q-icon>
</template>
This LP hasn't onboarded via <b>satmachineclient</b> yet, so
their DCA wallet isn't configured. Ask them to open the
satmachineclient extension once and the deposit will be
accepted next time.
</q-banner>
<q-input v-model.number="depositDialog.data.amount"
:label="depositMachineFiatCode ? `Amount (${depositMachineFiatCode})` : 'Amount (fiat)'"
type="number" step="0.01" min="0"
class="q-mb-md" dense outlined
:rules="[v => v > 0 || 'Must be > 0']">
<template v-slot:append>
<q-chip v-if="depositMachineFiatCode"
dense color="grey-3" text-color="grey-9"
:style="{fontWeight: 500}">
<span v-text="depositMachineFiatCode"></span>
<q-tooltip>
Currency is set by the selected LP's machine
(<code v-text="depositMachineFiatCode"></code>) — not
operator-editable. See aiolabs/satmachineadmin#26.
</q-tooltip>
</q-chip>
</template>
</q-input>
<q-input v-model="depositDialog.data.notes"
label="Notes (optional)"
type="textarea" autogrow
class="q-mb-md" dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
:loading="depositDialog.saving"
:disable="depositDialog.mode === 'add' && (!selectedDepositClient || !selectedDepositClient.lp_onboarded)"
@click="submitDeposit"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- REJECT DEPOSIT DIALOG (status update with notes) -->
<!-- =============================================================== -->
<q-dialog v-model="rejectDepositDialog.show" persistent>
<q-card :style="{width: '420px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Reject deposit</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-sm" :style="{opacity: 0.7}">
The deposit will be marked rejected and won't count toward the LP's
balance. Optional reason for the audit trail.
</p>
<q-input v-model="rejectDepositDialog.notes"
label="Reason (optional)"
type="textarea" autogrow
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="red" label="Reject"
:loading="rejectDepositDialog.saving"
@click="submitRejectDeposit"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD / EDIT CLIENT DIALOGS -->
<!-- =============================================================== -->
<q-dialog v-model="clientDialog.show" persistent>
<q-card :style="{width: '520px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6" v-text="clientDialog.mode === 'add' ? 'Register liquidity provider' : 'Edit LP'"></div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
v-if="clientDialog.mode === 'add'">
Enrol an LP at one of your machines. Wallet, DCA mode, and
autoforward are configured by the LP themselves via the
<b>satmachineclient</b> extension — you can't set them here.
Deposits are refused until the LP has registered.
</p>
<q-select
v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.machine_id"
:options="machineOptions"
label="At machine"
emit-value map-options
class="q-mb-md"
dense outlined
:rules="[v => !!v || 'Required']"></q-select>
<q-input v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.user_id"
label="LP's LNbits user_id"
hint="The LP shares this from their LNbits account settings"
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']"></q-input>
<q-input v-model="clientDialog.data.username"
label="Display name (optional)"
hint="Operator-facing label only; doesn't affect distribution"
class="q-mb-md" dense outlined></q-input>
<q-select v-if="clientDialog.mode === 'edit'"
v-model="clientDialog.data.status"
:options="['active', 'paused', 'closed']"
label="Status"
class="q-mb-md" dense outlined></q-select>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
:label="clientDialog.mode === 'add' ? 'Register' : 'Save'"
:loading="clientDialog.saving"
@click="submitClient"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- SETTLE BALANCE DIALOG (closes spirekeeper#2) -->
<!-- =============================================================== -->
<q-dialog v-model="settleBalanceDialog.show" persistent>
<q-card :style="{width: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Settle LP balance</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section v-if="settleBalanceDialog.client">
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="payments" color="blue"></q-icon>
</template>
Pay the LP's remaining fiat balance in sats from your wallet at the
rate you choose. Useful to zero out small balances that would
otherwise shrink forever via proportional shares.
</q-banner>
<div v-if="settleBalanceDialog.balance" class="q-mb-md">
<div class="text-caption" :style="{opacity: 0.6}">Remaining balance</div>
<div :style="{fontSize: '1.2em', fontWeight: 500}"
v-text="formatFiat(settleBalanceDialog.balance.remaining_balance, settleBalanceDialog.balance.currency)"></div>
</div>
<q-select v-model="settleBalanceDialog.data.funding_wallet_id"
:options="walletOptions"
label="Funding wallet (yours)"
emit-value map-options
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Pick a wallet']"></q-select>
<q-input v-model.number="settleBalanceDialog.data.exchange_rate"
label="Exchange rate (sats per 1 fiat unit)"
hint="You set the rate. Use exchange spot, midpoint, or a favourable gesture."
type="number" step="0.0001" min="0"
class="q-mb-md" dense outlined
:rules="[v => v > 0 || 'Must be > 0']"></q-input>
<q-input v-model.number="settleBalanceDialog.data.amount_fiat"
label="Amount (fiat) — leave blank to settle full remaining"
type="number" step="0.01"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="settleBalanceDialog.data.notes"
label="Notes (optional, audit memo)"
type="textarea" autogrow
class="q-mb-md" dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Settle balance"
:loading="settleBalanceDialog.saving"
@click="submitSettleBalance"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) -->
<!-- =============================================================== -->
<q-dialog v-model="editMachineDialog.show" persistent>
<q-card :style="{width: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Edit machine</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<q-input v-model="editMachineDialog.data.name"
label="Machine name" class="q-mb-md" dense outlined></q-input>
<q-input v-model="editMachineDialog.data.location"
label="Location" class="q-mb-md" dense outlined></q-input>
<q-select
v-model="editMachineDialog.data.wallet_id"
:options="walletOptions"
label="Wallet"
emit-value map-options
class="q-mb-md"
dense outlined></q-select>
<q-input v-model="editMachineDialog.data.fiat_code"
label="Fiat code" class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee (decimal fraction, 0-0.15)"
hint="Sits on top of the platform cash-in fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee (decimal fraction, 0-0.15)"
hint="Sits on top of the platform cash-out fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-toggle v-model="editMachineDialog.data.is_active"
label="Active (receives settlements)" class="q-mb-md"></q-toggle>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
color="primary" label="Save"
:loading="editMachineDialog.saving"
@click="submitEditMachine"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</div>
</div>
{% endblock %}