feat(v2): Clients tab — LP management + settle balance modal (P9c)

Adds operator-scoped LP (liquidity provider) management. Operators
register LPs against specific machines, monitor remaining balances, and
settle small remainders via the P3e endpoint.

Template (Clients tab content + dialogs):
  - Table with columns: machine, LP (username/short user_id), wallet,
    DCA mode badge, remaining balance (color-coded: green if positive,
    grey if zero), autoforward icon (with tooltip showing LN address),
    status badge, action menu
  - Empty-state banners: orange if no machines yet (LPs are
    machine-scoped), blue if machines exist but no LPs registered
  - Register-LP dialog: machine select + user_id + wallet_id + display
    name + DCA mode (flow / fixed) + fixed-mode daily limit (conditional)
    + autoforward toggle + autoforward LN address (conditional)
  - Edit-LP dialog: same minus immutable user_id/wallet_id, plus status
    select (active/paused/closed)
  - Settle-balance dialog (closes #4): funding wallet select + exchange
    rate (operator-supplied) + optional amount_fiat (blank = full
    remaining) + notes textarea. Shows the LP's current remaining
    balance prominently before submission.

JS:
  - loadClients pulls all operator's LPs across their fleet
  - Per-LP balance summaries pre-loaded (one GET per LP — N+1, captured
    in review issue #11 M3 for follow-up with a single grouped JOIN)
  - openAddClientDialog / openEditClientDialog with separate cleaner
    helpers (_cleanClientCreate vs _cleanClientUpdate) since the v2
    API immutable-field rules differ between create and update
  - openSettleBalanceDialog refreshes balance immediately before
    showing the modal so the operator sees the up-to-date number
  - confirmDeleteClient + DELETE wired
  - machineNameById helper for displaying which machine an LP is at
  - machineOptions computed for the register-LP machine select
  - machinesById computed cache (avoids O(N*M) lookups in render loop)

Routes wired:
  GET    /api/v1/dca/clients
  GET    /api/v1/dca/clients/{id}/balance
  POST   /api/v1/dca/clients
  PUT    /api/v1/dca/clients/{id}
  DELETE /api/v1/dca/clients/{id}
  POST   /api/v1/dca/clients/{id}/settle

Refs: aiolabs/satmachineadmin#9 — closes the Clients-tab gap in #4 +
exposes the autoforward setting (#8) in operator UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:03:29 +02:00
commit 0800a1acb0
2 changed files with 498 additions and 2 deletions

View file

@ -167,9 +167,112 @@
<!-- PLACEHOLDERS for tabs that land in P9bP9g -->
<!-- ============================================================= -->
<q-tab-panel name="clients">
<q-banner class="bg-grey-2 text-grey-9">
Clients tab — pending P9c.
<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" />
</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" />
</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" />
</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="wallet_id">
<code :style="{fontSize: '0.8em'}"
v-text="shortId(props.row.wallet_id)"></code>
</q-td>
<q-td key="dca_mode">
<q-badge :color="props.row.dca_mode === 'flow' ? 'blue' : 'purple'"
:label="props.row.dca_mode" />
</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="autoforward">
<q-icon v-if="props.row.autoforward_enabled"
name="forward_to_inbox" color="primary" size="sm">
<q-tooltip>
Auto-forward enabled →
<span v-text="props.row.autoforward_ln_address || '(no address)'"></span>
</q-tooltip>
</q-icon>
<q-icon v-else name="forward" color="grey-5" size="sm">
<q-tooltip>Auto-forward disabled</q-tooltip>
</q-icon>
</q-td>
<q-td key="status">
<q-badge
:color="props.row.status === 'active' ? 'green' : 'grey'"
:label="props.row.status" />
</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-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-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-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">
<q-banner class="bg-grey-2 text-grey-9">
@ -523,6 +626,150 @@
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD / EDIT CLIENT DIALOGS -->
<!-- =============================================================== -->
<q-dialog v-model="clientDialog.show" persistent>
<q-card :style="{minWidth: '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-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
v-if="clientDialog.mode === 'add'">
LPs receive DCA distributions proportional to their remaining
balance. Each LP is scoped to a single machine; the same LP user
can register at multiple machines as separate rows.
</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-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 v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.wallet_id"
label="LP's wallet_id (receives DCA)"
hint="The LP's wallet where their sats land"
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']" />
<q-input v-model="clientDialog.data.username"
label="Display name (optional)"
class="q-mb-md" dense outlined />
<q-select v-model="clientDialog.data.dca_mode"
:options="[{label: 'Flow (proportional)', value: 'flow'},
{label: 'Fixed (daily limit)', value: 'fixed'}]"
label="DCA mode"
emit-value map-options
class="q-mb-md" dense outlined />
<q-input v-if="clientDialog.data.dca_mode === 'fixed'"
v-model.number="clientDialog.data.fixed_mode_daily_limit"
label="Fixed mode daily limit (fiat)"
type="number" step="0.01"
class="q-mb-md" dense outlined />
<q-toggle v-model="clientDialog.data.autoforward_enabled"
label="Auto-forward DCA to external LN address"
class="q-mb-md" />
<q-input v-if="clientDialog.data.autoforward_enabled"
v-model="clientDialog.data.autoforward_ln_address"
label="LN address (e.g. user@walletofsatoshi.com)"
hint="LP-controlled; failures leave sats safely in LP's LNbits wallet"
class="q-mb-md" dense outlined />
<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-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn color="primary"
:label="clientDialog.mode === 'add' ? 'Register' : 'Save'"
:loading="clientDialog.saving"
@click="submitClient" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- SETTLE BALANCE DIALOG (closes satmachineadmin#4) -->
<!-- =============================================================== -->
<q-dialog v-model="settleBalanceDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Settle LP balance</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</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" />
</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-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 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 v-model="settleBalanceDialog.data.notes"
label="Notes (optional, audit memo)"
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="Settle balance"
:loading="settleBalanceDialog.saving"
@click="submitSettleBalance" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) -->
<!-- =============================================================== -->