feat(v2): machine detail dialog — settlements + per-row actions (P9b)
Adds the operator's primary workspace: a full-screen dialog opened from
any Fleet row that shows the machine's settlement history with action
menus for retry / partial-dispense / force-reset / note-add.
Template (templates/satmachineadmin/index.html):
- Full-screen Quasar dialog with q-bar header (machine name + fiat
chip + reload + close)
- Machine metadata strip: npub (copyable), wallet_id, location,
fallback_commission_pct
- Settlements table: status badge, time, gross / net / commission
(with super/op breakdown beneath), fiat amount, payment_hash short
- Notes blob expansion under each settlement row (pre-formatted)
- Per-row action menu (q-btn-dropdown):
• Add note — always available
• Retry — when status='errored'
• Partial dispense — when status in {pending, errored}
• Force-reset — when status in {pending, processing}
- Warning icon (⚠) on rows where used_fallback_split=true, namechecking
aiolabs/lamassu-next#44 in the tooltip
- Three sub-dialogs:
• Partial-dispense with fraction/sats toggle + notes input
• Add-note dialog (free-form, non-empty validation)
• (Retry/force-reset use Quasar.Dialog inline)
JS (static/js/index.js):
- viewMachine() opens detail and triggers reloadMachineDetail()
- GET /api/v1/dca/machines/{id}/settlements feeds the table
- confirmRetrySettlement → POST .../retry
- openPartialDispense → POST .../partial-dispense
- confirmForceReset → POST .../force-reset
- openSettlementNote → POST .../notes
- _replaceSettlement() updates the table row in-place from PUT/POST
responses so the operator sees instant feedback without a reload
- settlementStatusColor() maps statuses to Quasar badge colors
- formatSats / formatFiat / formatTime helpers; respect locale
Also: added data/ + *.sqlite3 to .gitignore so the
2026-05-14 auth-key leak can't recur from this repo (the equivalent
fix already landed in satmachineclient on the matching branch).
Refs: aiolabs/satmachineadmin#9 — closes the operator-detail-view
gap for #3 (partial dispense) + #4 (settlement) UX
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21d159d709
commit
13ac33047b
3 changed files with 480 additions and 9 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -2,3 +2,8 @@ __pycache__
|
||||||
node_modules
|
node_modules
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# LNbits runtime data — auth keys, dev DB files, etc.
|
||||||
|
data/
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-journal
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,20 @@
|
||||||
// - For pale backgrounds (bg-*-1), pair with explicit dark text class
|
// - For pale backgrounds (bg-*-1), pair with explicit dark text class
|
||||||
// so dark-mode users don't get unreadable white-on-cream.
|
// so dark-mode users don't get unreadable white-on-cream.
|
||||||
|
|
||||||
const SUPER_FEE_PATH = '/satmachineadmin/api/v1/dca/super-config'
|
const API = '/satmachineadmin/api/v1/dca'
|
||||||
const MACHINES_PATH = '/satmachineadmin/api/v1/dca/machines'
|
const SUPER_FEE_PATH = `${API}/super-config`
|
||||||
const STUCK_PATH = '/satmachineadmin/api/v1/dca/settlements/stuck'
|
const MACHINES_PATH = `${API}/machines`
|
||||||
|
const SETTLEMENTS_PATH = `${API}/settlements`
|
||||||
|
const STUCK_PATH = `${API}/settlements/stuck`
|
||||||
|
|
||||||
|
const SETTLEMENT_STATUS_COLOR = {
|
||||||
|
pending: 'grey',
|
||||||
|
processing: 'blue',
|
||||||
|
processed: 'green',
|
||||||
|
partial: 'orange',
|
||||||
|
refunded: 'purple',
|
||||||
|
errored: 'red'
|
||||||
|
}
|
||||||
|
|
||||||
window.app = Vue.createApp({
|
window.app = Vue.createApp({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
|
|
@ -50,6 +61,25 @@ window.app = Vue.createApp({
|
||||||
pagination: {rowsPerPage: 10, sortBy: 'name'}
|
pagination: {rowsPerPage: 10, sortBy: 'name'}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settlementsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||||
|
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
|
||||||
|
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
|
||||||
|
{name: 'net_sats', label: 'Net (→ LPs)', field: 'net_sats', align: 'right'},
|
||||||
|
{
|
||||||
|
name: 'commission_sats',
|
||||||
|
label: 'Commission',
|
||||||
|
field: 'commission_sats',
|
||||||
|
align: 'right'
|
||||||
|
},
|
||||||
|
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
|
||||||
|
{name: 'payment_hash', label: 'Hash', field: 'payment_hash', align: 'left'},
|
||||||
|
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||||
|
],
|
||||||
|
pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true}
|
||||||
|
},
|
||||||
|
|
||||||
// Dialog state ---------------------------------------------------
|
// Dialog state ---------------------------------------------------
|
||||||
addMachineDialog: {
|
addMachineDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
|
|
@ -60,6 +90,27 @@ window.app = Vue.createApp({
|
||||||
show: false,
|
show: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
data: {}
|
data: {}
|
||||||
|
},
|
||||||
|
machineDetail: {
|
||||||
|
show: false,
|
||||||
|
loading: false,
|
||||||
|
machine: null,
|
||||||
|
settlements: []
|
||||||
|
},
|
||||||
|
partialDispenseDialog: {
|
||||||
|
show: false,
|
||||||
|
saving: false,
|
||||||
|
settlement: null,
|
||||||
|
mode: 'fraction',
|
||||||
|
dispensed_fraction: null,
|
||||||
|
dispensed_sats: null,
|
||||||
|
notes: ''
|
||||||
|
},
|
||||||
|
noteDialog: {
|
||||||
|
show: false,
|
||||||
|
saving: false,
|
||||||
|
settlement: null,
|
||||||
|
note: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -225,16 +276,162 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Future: drill into a machine's detail view. Stub for P9a; P9b
|
// Machine detail dialog (P9b)
|
||||||
// wires the actual settlement / telemetry / commission-splits panel.
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
viewMachine(machine) {
|
async viewMachine(machine) {
|
||||||
Quasar.Notify.create({
|
this.machineDetail.machine = machine
|
||||||
type: 'info',
|
this.machineDetail.settlements = []
|
||||||
message: `Machine detail view lands in P9b. (selected ${machine.id})`
|
this.machineDetail.show = true
|
||||||
|
await this.reloadMachineDetail()
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadMachineDetail() {
|
||||||
|
if (!this.machineDetail.machine) return
|
||||||
|
this.machineDetail.loading = true
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`${MACHINES_PATH}/${this.machineDetail.machine.id}/settlements`
|
||||||
|
)
|
||||||
|
this.machineDetail.settlements = data || []
|
||||||
|
} catch (e) {
|
||||||
|
this._notifyError(e, 'Failed to load settlements')
|
||||||
|
} finally {
|
||||||
|
this.machineDetail.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
settlementStatusColor(status) {
|
||||||
|
return SETTLEMENT_STATUS_COLOR[status] || 'grey'
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Settlement actions: retry, partial-dispense, force-reset, note
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
confirmRetrySettlement(settlement) {
|
||||||
|
Quasar.Dialog.create({
|
||||||
|
title: 'Retry distribution?',
|
||||||
|
message:
|
||||||
|
'Voids any failed legs and re-runs the distribution chain. ' +
|
||||||
|
'Completed legs are never re-paid.',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
`${SETTLEMENTS_PATH}/${settlement.id}/retry`
|
||||||
|
)
|
||||||
|
this._replaceSettlement(data)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Settlement ${this.shortId(settlement.id)} re-run`
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this._notifyError(e, 'Retry failed')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
confirmForceReset(settlement) {
|
||||||
|
Quasar.Dialog.create({
|
||||||
|
title: 'Force-reset stuck settlement?',
|
||||||
|
message:
|
||||||
|
`Flips status '${settlement.status}' → 'errored' so you can then ` +
|
||||||
|
'retry. Only use if the processor truly crashed mid-flight — fresh ' +
|
||||||
|
'settlements are refused (default 30-minute age guard).',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
`${SETTLEMENTS_PATH}/${settlement.id}/force-reset`
|
||||||
|
)
|
||||||
|
this._replaceSettlement(data)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: `Settlement marked errored — hit Retry next`
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this._notifyError(e, 'Force-reset failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
openPartialDispense(settlement) {
|
||||||
|
this.partialDispenseDialog.settlement = settlement
|
||||||
|
this.partialDispenseDialog.mode = 'fraction'
|
||||||
|
this.partialDispenseDialog.dispensed_fraction = null
|
||||||
|
this.partialDispenseDialog.dispensed_sats = null
|
||||||
|
this.partialDispenseDialog.notes = ''
|
||||||
|
this.partialDispenseDialog.show = true
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitPartialDispense() {
|
||||||
|
const d = this.partialDispenseDialog
|
||||||
|
const body = {notes: d.notes || null}
|
||||||
|
if (d.mode === 'fraction') {
|
||||||
|
body.dispensed_fraction = Number(d.dispensed_fraction)
|
||||||
|
} else {
|
||||||
|
body.dispensed_sats = Number(d.dispensed_sats)
|
||||||
|
}
|
||||||
|
d.saving = true
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
`${SETTLEMENTS_PATH}/${d.settlement.id}/partial-dispense`,
|
||||||
|
null,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
this._replaceSettlement(data)
|
||||||
|
d.show = false
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Partial dispense applied; distribution re-running'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this._notifyError(e, 'Partial dispense failed')
|
||||||
|
} finally {
|
||||||
|
d.saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openSettlementNote(settlement) {
|
||||||
|
this.noteDialog.settlement = settlement
|
||||||
|
this.noteDialog.note = ''
|
||||||
|
this.noteDialog.show = true
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitNote() {
|
||||||
|
const d = this.noteDialog
|
||||||
|
if (!d.note || !d.note.trim()) return
|
||||||
|
d.saving = true
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
`${SETTLEMENTS_PATH}/${d.settlement.id}/notes`,
|
||||||
|
null,
|
||||||
|
{note: d.note.trim()}
|
||||||
|
)
|
||||||
|
this._replaceSettlement(data)
|
||||||
|
d.show = false
|
||||||
|
Quasar.Notify.create({type: 'positive', message: 'Note added'})
|
||||||
|
} catch (e) {
|
||||||
|
this._notifyError(e, 'Failed to add note')
|
||||||
|
} finally {
|
||||||
|
d.saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_replaceSettlement(updated) {
|
||||||
|
if (!updated) return
|
||||||
|
const idx = this.machineDetail.settlements.findIndex(
|
||||||
|
s => s.id === updated.id
|
||||||
|
)
|
||||||
|
if (idx >= 0) this.machineDetail.settlements[idx] = updated
|
||||||
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
@ -249,6 +446,23 @@ window.app = Vue.createApp({
|
||||||
return id.length <= 12 ? id : id.slice(0, 8) + '…'
|
return id.length <= 12 ? id : id.slice(0, 8) + '…'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
formatSats(n) {
|
||||||
|
if (n == null) return '—'
|
||||||
|
return Number(n).toLocaleString()
|
||||||
|
},
|
||||||
|
|
||||||
|
formatFiat(amount, code) {
|
||||||
|
if (amount == null) return '—'
|
||||||
|
return `${Number(amount).toFixed(2)} ${code || ''}`.trim()
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(ts) {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (isNaN(d.getTime())) return String(ts)
|
||||||
|
return d.toLocaleString()
|
||||||
|
},
|
||||||
|
|
||||||
copy(text) {
|
copy(text) {
|
||||||
if (!text) return
|
if (!text) return
|
||||||
Quasar.copyToClipboard(text).then(() => {
|
Quasar.copyToClipboard(text).then(() => {
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,258 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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-btn flat dense round icon="refresh"
|
||||||
|
@click="reloadMachineDetail"
|
||||||
|
:loading="machineDetail.loading">
|
||||||
|
<q-tooltip>Reload settlements</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 class="col-6 col-md-3">
|
||||||
|
<div class="text-caption" :style="{opacity: 0.6}">
|
||||||
|
Fallback commission %
|
||||||
|
</div>
|
||||||
|
<span v-text="(machineDetail.machine.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-mb-md" />
|
||||||
|
|
||||||
|
<div class="row items-center 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-icon v-if="props.row.used_fallback_split"
|
||||||
|
name="warning_amber" color="orange" size="sm"
|
||||||
|
class="q-ml-xs">
|
||||||
|
<q-tooltip>
|
||||||
|
Fallback split — bitSpire didn't supply per-tx
|
||||||
|
net/fee. See lamassu-next#44.
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</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="gross_sats" class="text-right">
|
||||||
|
<span v-text="formatSats(props.row.gross_sats)"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="net_sats" class="text-right">
|
||||||
|
<span v-text="formatSats(props.row.net_sats)"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td key="commission_sats" class="text-right">
|
||||||
|
<span v-text="formatSats(props.row.commission_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-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-item-section>
|
||||||
|
<q-item-section>Retry distribution</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-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-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-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- =============================================================== -->
|
||||||
|
<!-- PARTIAL-DISPENSE DIALOG -->
|
||||||
|
<!-- =============================================================== -->
|
||||||
|
<q-dialog v-model="partialDispenseDialog.show" persistent>
|
||||||
|
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">Apply partial dispense</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense v-close-popup />
|
||||||
|
</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" />
|
||||||
|
</template>
|
||||||
|
Original gross:
|
||||||
|
<b v-text="formatSats(partialDispenseDialog.settlement.gross_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 name="sats" label="By exact sats" />
|
||||||
|
</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-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.gross_sats"
|
||||||
|
dense outlined />
|
||||||
|
</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-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn color="warning"
|
||||||
|
label="Apply partial dispense"
|
||||||
|
:loading="partialDispenseDialog.saving"
|
||||||
|
@click="submitPartialDispense" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- =============================================================== -->
|
||||||
|
<!-- ADD-NOTE DIALOG -->
|
||||||
|
<!-- =============================================================== -->
|
||||||
|
<q-dialog v-model="noteDialog.show" persistent>
|
||||||
|
<q-card :style="{minWidth: '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-btn icon="close" flat round dense v-close-popup />
|
||||||
|
</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-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Cancel" v-close-popup />
|
||||||
|
<q-btn color="primary" label="Save note"
|
||||||
|
:loading="noteDialog.saving"
|
||||||
|
@click="submitNote" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<!-- =============================================================== -->
|
<!-- =============================================================== -->
|
||||||
<!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) -->
|
<!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) -->
|
||||||
<!-- =============================================================== -->
|
<!-- =============================================================== -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue