feat(v2): Deposits tab — record/confirm/reject workflow (P9d)
Operator records fiat handed in by LPs and drives the pending → confirmed
status transition that promotes deposits into LP balances.
Template (Deposits tab content + dialogs):
- Filter strip: status dropdown (all/pending/confirmed/rejected) and
LP dropdown (filtered by all the operator's LPs across machines)
- Table columns: status badge, LP (+ machine subtitle), amount, created
at, confirmed at, notes, action menu
- Action menu (only enabled on pending status — confirmed/rejected are
immutable for audit):
• Confirm — flips to status='confirmed' + refreshes LP balance
• Reject — opens reject dialog for optional reason notes
• Edit — amount/currency/notes change
• Delete
- Empty-state banners: orange if no LPs (deposits are LP-scoped), blue
if LPs exist but no deposits yet, grey if filters return nothing
- Record-deposit dialog: LP select (auto-derives machine), amount,
currency, notes
- Edit-deposit dialog: amount/currency/notes; LP+machine immutable
- Reject-deposit dialog: optional reason text persisted with the status
JS:
- loadDeposits, depositStatusColor, clientUsernameById helpers
- depositClientOptions computed: includes machine name in each option
label so operators see exactly where the deposit will land
- filteredDeposits computed: client-side filter on the loaded list
(no server-side filter param — operator's deposit volume small enough)
- submitDeposit handles both create and update paths; the create body
explicitly includes machine_id (auto-derived from the selected LP)
so the server can cross-check (client_id, machine_id) alignment
- confirmDepositStatus refreshes the LP balance after confirming,
since the confirmed deposit now affects remaining_balance display
Routes wired:
GET /api/v1/dca/deposits
POST /api/v1/dca/deposits
PUT /api/v1/dca/deposits/{id}
PUT /api/v1/dca/deposits/{id}/status
DELETE /api/v1/dca/deposits/{id}
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0800a1acb0
commit
ce4d7e4dd6
2 changed files with 436 additions and 2 deletions
|
|
@ -18,6 +18,13 @@ const MACHINES_PATH = `${API}/machines`
|
|||
const SETTLEMENTS_PATH = `${API}/settlements`
|
||||
const STUCK_PATH = `${API}/settlements/stuck`
|
||||
const CLIENTS_PATH = `${API}/clients`
|
||||
const DEPOSITS_PATH = `${API}/deposits`
|
||||
|
||||
const DEPOSIT_STATUS_COLOR = {
|
||||
pending: 'orange',
|
||||
confirmed: 'green',
|
||||
rejected: 'red'
|
||||
}
|
||||
|
||||
const SETTLEMENT_STATUS_COLOR = {
|
||||
pending: 'grey',
|
||||
|
|
@ -43,8 +50,14 @@ window.app = Vue.createApp({
|
|||
machines: [],
|
||||
clients: [],
|
||||
clientBalances: {}, // {client_id: ClientBalanceSummary}
|
||||
deposits: [],
|
||||
worklistCount: 0,
|
||||
|
||||
depositsFilter: {
|
||||
status: null,
|
||||
client_id: null
|
||||
},
|
||||
|
||||
// UI configuration -----------------------------------------------
|
||||
machinesTable: {
|
||||
columns: [
|
||||
|
|
@ -64,6 +77,24 @@ window.app = Vue.createApp({
|
|||
pagination: {rowsPerPage: 10, sortBy: 'name'}
|
||||
},
|
||||
|
||||
depositsTable: {
|
||||
columns: [
|
||||
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||
{name: 'client', label: 'LP / Machine', field: 'client_id', align: 'left'},
|
||||
{name: 'amount', label: 'Amount', field: 'amount', align: 'right'},
|
||||
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
|
||||
{
|
||||
name: 'confirmed_at',
|
||||
label: 'Confirmed',
|
||||
field: 'confirmed_at',
|
||||
align: 'left'
|
||||
},
|
||||
{name: 'notes', label: 'Notes', field: 'notes', align: 'left'},
|
||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||
],
|
||||
pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true}
|
||||
},
|
||||
|
||||
clientsTable: {
|
||||
columns: [
|
||||
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
||||
|
|
@ -140,6 +171,19 @@ window.app = Vue.createApp({
|
|||
mode: 'add', // 'add' | 'edit'
|
||||
data: this._emptyClientForm()
|
||||
},
|
||||
depositDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
mode: 'add',
|
||||
data: this._emptyDepositForm()
|
||||
},
|
||||
rejectDepositDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
deposit: null,
|
||||
notes: ''
|
||||
},
|
||||
|
||||
settleBalanceDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
|
|
@ -167,6 +211,22 @@ window.app = Vue.createApp({
|
|||
value: m.id
|
||||
}))
|
||||
},
|
||||
depositClientOptions() {
|
||||
return this.clients.map(c => ({
|
||||
label: `${c.username || this.shortId(c.user_id)} @ ${this.machineNameById(c.machine_id)}`,
|
||||
value: c.id
|
||||
}))
|
||||
},
|
||||
filteredDeposits() {
|
||||
let rows = this.deposits
|
||||
if (this.depositsFilter.status) {
|
||||
rows = rows.filter(d => d.status === this.depositsFilter.status)
|
||||
}
|
||||
if (this.depositsFilter.client_id) {
|
||||
rows = rows.filter(d => d.client_id === this.depositsFilter.client_id)
|
||||
}
|
||||
return rows
|
||||
},
|
||||
machinesById() {
|
||||
const map = {}
|
||||
for (const m of this.machines) map[m.id] = m
|
||||
|
|
@ -189,6 +249,7 @@ window.app = Vue.createApp({
|
|||
this.loadSuperConfig(),
|
||||
this.loadMachines(),
|
||||
this.loadClients(),
|
||||
this.loadDeposits(),
|
||||
this.loadWorklistCount()
|
||||
])
|
||||
} finally {
|
||||
|
|
@ -196,6 +257,26 @@ window.app = Vue.createApp({
|
|||
}
|
||||
},
|
||||
|
||||
async loadDeposits() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', DEPOSITS_PATH)
|
||||
this.deposits = data || []
|
||||
} catch (e) {
|
||||
this.deposits = []
|
||||
this._notifyError(e, 'Failed to load deposits')
|
||||
}
|
||||
},
|
||||
|
||||
depositStatusColor(status) {
|
||||
return DEPOSIT_STATUS_COLOR[status] || 'grey'
|
||||
},
|
||||
|
||||
clientUsernameById(clientId) {
|
||||
const c = this.clients.find(x => x.id === clientId)
|
||||
if (!c) return this.shortId(clientId)
|
||||
return c.username || this.shortId(c.user_id)
|
||||
},
|
||||
|
||||
async loadClients() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', CLIENTS_PATH)
|
||||
|
|
@ -582,6 +663,147 @@ window.app = Vue.createApp({
|
|||
})
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Deposits (P9d)
|
||||
// -----------------------------------------------------------------
|
||||
openAddDepositDialog() {
|
||||
this.depositDialog.mode = 'add'
|
||||
this.depositDialog.data = this._emptyDepositForm()
|
||||
this.depositDialog.show = true
|
||||
},
|
||||
|
||||
openEditDepositDialog(deposit) {
|
||||
this.depositDialog.mode = 'edit'
|
||||
this.depositDialog.data = {
|
||||
id: deposit.id,
|
||||
client_id: deposit.client_id,
|
||||
amount: deposit.amount,
|
||||
currency: deposit.currency,
|
||||
notes: deposit.notes || ''
|
||||
}
|
||||
this.depositDialog.show = true
|
||||
},
|
||||
|
||||
async submitDeposit() {
|
||||
const d = this.depositDialog.data
|
||||
this.depositDialog.saving = true
|
||||
try {
|
||||
if (this.depositDialog.mode === 'add') {
|
||||
// machine_id is server-cross-checked but we send it explicitly.
|
||||
const client = this.clients.find(c => c.id === d.client_id)
|
||||
if (!client) throw new Error('client not found')
|
||||
const body = {
|
||||
client_id: d.client_id,
|
||||
machine_id: client.machine_id,
|
||||
amount: Number(d.amount),
|
||||
currency: (d.currency || 'GTQ').trim(),
|
||||
notes: (d.notes || '').trim() || null
|
||||
}
|
||||
const {data} = await LNbits.api.request('POST', DEPOSITS_PATH, null, body)
|
||||
this.deposits.unshift(data)
|
||||
Quasar.Notify.create({type: 'positive', message: 'Deposit recorded'})
|
||||
} else {
|
||||
const body = {
|
||||
amount: Number(d.amount),
|
||||
currency: (d.currency || 'GTQ').trim(),
|
||||
notes: (d.notes || '').trim() || null
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT', `${DEPOSITS_PATH}/${d.id}`, null, body
|
||||
)
|
||||
this._replaceDeposit(data)
|
||||
Quasar.Notify.create({type: 'positive', message: 'Deposit updated'})
|
||||
}
|
||||
this.depositDialog.show = false
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Save failed')
|
||||
} finally {
|
||||
this.depositDialog.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
confirmDepositStatus(deposit, newStatus) {
|
||||
const verb = newStatus === 'confirmed' ? 'Confirm' : 'Reject'
|
||||
Quasar.Dialog.create({
|
||||
title: `${verb} deposit?`,
|
||||
message:
|
||||
newStatus === 'confirmed'
|
||||
? `Confirming will count this toward the LP's DCA balance.`
|
||||
: `Rejecting marks it ignored; it won't affect balances.`,
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`${DEPOSITS_PATH}/${deposit.id}/status`,
|
||||
null,
|
||||
{status: newStatus, notes: deposit.notes || null}
|
||||
)
|
||||
this._replaceDeposit(data)
|
||||
// Confirming changes the LP balance — refresh it.
|
||||
if (newStatus === 'confirmed') {
|
||||
await this._loadClientBalance(deposit.client_id)
|
||||
}
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: `Deposit ${newStatus}`
|
||||
})
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Status update failed')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
openRejectDepositDialog(deposit) {
|
||||
this.rejectDepositDialog.deposit = deposit
|
||||
this.rejectDepositDialog.notes = ''
|
||||
this.rejectDepositDialog.show = true
|
||||
},
|
||||
|
||||
async submitRejectDeposit() {
|
||||
const d = this.rejectDepositDialog
|
||||
d.saving = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`${DEPOSITS_PATH}/${d.deposit.id}/status`,
|
||||
null,
|
||||
{status: 'rejected', notes: d.notes || null}
|
||||
)
|
||||
this._replaceDeposit(data)
|
||||
d.show = false
|
||||
Quasar.Notify.create({type: 'positive', message: 'Deposit rejected'})
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Reject failed')
|
||||
} finally {
|
||||
d.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
confirmDeleteDeposit(deposit) {
|
||||
Quasar.Dialog.create({
|
||||
title: 'Delete deposit?',
|
||||
message: 'Only pending deposits can be deleted.',
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request('DELETE', `${DEPOSITS_PATH}/${deposit.id}`)
|
||||
this.deposits = this.deposits.filter(x => x.id !== deposit.id)
|
||||
Quasar.Notify.create({type: 'positive', message: 'Deposit deleted'})
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Delete failed')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
_replaceDeposit(updated) {
|
||||
if (!updated) return
|
||||
const idx = this.deposits.findIndex(d => d.id === updated.id)
|
||||
if (idx >= 0) this.deposits[idx] = updated
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Settle balance (P3e — closes #4)
|
||||
// -----------------------------------------------------------------
|
||||
|
|
@ -673,6 +895,15 @@ window.app = Vue.createApp({
|
|||
})
|
||||
},
|
||||
|
||||
_emptyDepositForm() {
|
||||
return {
|
||||
client_id: null,
|
||||
amount: null,
|
||||
currency: 'GTQ',
|
||||
notes: ''
|
||||
}
|
||||
},
|
||||
|
||||
_emptyClientForm() {
|
||||
return {
|
||||
machine_id: null,
|
||||
|
|
|
|||
|
|
@ -275,9 +275,138 @@
|
|||
</q-table>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="deposits">
|
||||
<q-banner class="bg-grey-2 text-grey-9">
|
||||
Deposits tab — pending P9d.
|
||||
<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" />
|
||||
</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 />
|
||||
</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 />
|
||||
</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" />
|
||||
</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" />
|
||||
</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-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-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-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-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-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">
|
||||
<q-banner class="bg-grey-2 text-grey-9">
|
||||
|
|
@ -626,6 +755,80 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- ADD / EDIT DEPOSIT DIALOG -->
|
||||
<!-- =============================================================== -->
|
||||
<q-dialog v-model="depositDialog.show" persistent>
|
||||
<q-card :style="{minWidth: '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-btn icon="close" flat round dense v-close-popup />
|
||||
</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-input v-model.number="depositDialog.data.amount"
|
||||
label="Amount (fiat)"
|
||||
type="number" step="0.01" min="0"
|
||||
class="q-mb-md" dense outlined
|
||||
:rules="[v => v > 0 || 'Must be > 0']" />
|
||||
|
||||
<q-input v-model="depositDialog.data.currency"
|
||||
label="Currency"
|
||||
class="q-mb-md" dense outlined />
|
||||
|
||||
<q-input v-model="depositDialog.data.notes"
|
||||
label="Notes (optional)"
|
||||
type="textarea" autogrow
|
||||
class="q-mb-md" dense outlined />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" v-close-popup />
|
||||
<q-btn color="primary"
|
||||
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
|
||||
:loading="depositDialog.saving"
|
||||
@click="submitDeposit" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- REJECT DEPOSIT DIALOG (status update with notes) -->
|
||||
<!-- =============================================================== -->
|
||||
<q-dialog v-model="rejectDepositDialog.show" persistent>
|
||||
<q-card :style="{minWidth: '420px', maxWidth: '95vw'}">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">Reject deposit</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}">
|
||||
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-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" v-close-popup />
|
||||
<q-btn color="red" label="Reject"
|
||||
:loading="rejectDepositDialog.saving"
|
||||
@click="submitRejectDeposit" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- ADD / EDIT CLIENT DIALOGS -->
|
||||
<!-- =============================================================== -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue