feat(v2)(ui): operator-side LP UI matches the new dca_lp authority split
Operator no longer chooses the LP's wallet / DCA mode / autoforward — those belong to the LP, written via satmachineclient. The Add LP / Edit LP dialogs reduce to (machine, user_id, optional username, status). The clients table loses the wallet / mode / autoforward columns and gains an "Onboarded" column showing whether the LP has a `dca_lp` row yet (server-side LEFT JOIN; `DcaClient.lp_onboarded`). Deposit creation gate (the structural enforcement of "must onboard first"): - Picker annotates each LP option with "— pending onboarding" and disables un-onboarded LP rows. - Selecting an un-onboarded LP shows an inline deep-orange banner explaining the LP needs to open satmachineclient once. - The Record button is `:disable`d in that state. The backend refuses with HTTP 422 anyway (see previous commit) — UI is just the first line of feedback. Backend wiring: - `DcaClient` model gains `lp_onboarded: bool = False`, populated at SELECT time via a shared `_CLIENT_SELECT` / `_CLIENT_FROM` fragment that LEFT JOINs `dca_lp` on `user_id`. All four list/ single-row read paths use it: by-id, by-(machine,user), by-machine, by-operator, by-user. No extra round-trip per row. - CSV export drops the removed columns; adds `lp_onboarded`. All 86 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
80b5a6d785
commit
cfad4e341c
4 changed files with 93 additions and 103 deletions
44
crud.py
44
crud.py
|
|
@ -201,9 +201,23 @@ async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# Shared SELECT fragment: client columns plus the LP-onboarded flag
|
||||||
|
# computed via LEFT JOIN on dca_lp. Returned as `lp_onboarded` (boolean
|
||||||
|
# 0/1 in SQLite, which Pydantic coerces to bool on the DcaClient model).
|
||||||
|
_CLIENT_SELECT = """
|
||||||
|
c.id, c.machine_id, c.user_id, c.username, c.status,
|
||||||
|
c.created_at, c.updated_at,
|
||||||
|
(lp.user_id IS NOT NULL) AS lp_onboarded
|
||||||
|
"""
|
||||||
|
_CLIENT_FROM = (
|
||||||
|
"satoshimachine.dca_clients c "
|
||||||
|
"LEFT JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_dca_client(client_id: str) -> Optional[DcaClient]:
|
async def get_dca_client(client_id: str) -> Optional[DcaClient]:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM satoshimachine.dca_clients WHERE id = :id",
|
f"SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} WHERE c.id = :id",
|
||||||
{"id": client_id},
|
{"id": client_id},
|
||||||
DcaClient,
|
DcaClient,
|
||||||
)
|
)
|
||||||
|
|
@ -213,9 +227,9 @@ async def get_dca_client_for_machine_user(
|
||||||
machine_id: str, user_id: str
|
machine_id: str, user_id: str
|
||||||
) -> Optional[DcaClient]:
|
) -> Optional[DcaClient]:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"""
|
f"""
|
||||||
SELECT * FROM satoshimachine.dca_clients
|
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
|
||||||
WHERE machine_id = :machine_id AND user_id = :user_id
|
WHERE c.machine_id = :machine_id AND c.user_id = :user_id
|
||||||
""",
|
""",
|
||||||
{"machine_id": machine_id, "user_id": user_id},
|
{"machine_id": machine_id, "user_id": user_id},
|
||||||
DcaClient,
|
DcaClient,
|
||||||
|
|
@ -224,10 +238,10 @@ async def get_dca_client_for_machine_user(
|
||||||
|
|
||||||
async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
|
async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
"""
|
f"""
|
||||||
SELECT * FROM satoshimachine.dca_clients
|
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
|
||||||
WHERE machine_id = :machine_id
|
WHERE c.machine_id = :machine_id
|
||||||
ORDER BY created_at DESC
|
ORDER BY c.created_at DESC
|
||||||
""",
|
""",
|
||||||
{"machine_id": machine_id},
|
{"machine_id": machine_id},
|
||||||
DcaClient,
|
DcaClient,
|
||||||
|
|
@ -237,9 +251,9 @@ async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
|
||||||
async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]:
|
async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]:
|
||||||
"""All clients across every machine this operator owns."""
|
"""All clients across every machine this operator owns."""
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
"""
|
f"""
|
||||||
SELECT c.*
|
SELECT {_CLIENT_SELECT}
|
||||||
FROM satoshimachine.dca_clients c
|
FROM {_CLIENT_FROM}
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
|
JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
|
||||||
WHERE m.operator_user_id = :uid
|
WHERE m.operator_user_id = :uid
|
||||||
ORDER BY c.created_at DESC
|
ORDER BY c.created_at DESC
|
||||||
|
|
@ -252,10 +266,10 @@ async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]
|
||||||
async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]:
|
async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]:
|
||||||
"""LP cross-operator view — every machine this LP is registered at."""
|
"""LP cross-operator view — every machine this LP is registered at."""
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
"""
|
f"""
|
||||||
SELECT * FROM satoshimachine.dca_clients
|
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
|
||||||
WHERE user_id = :user_id
|
WHERE c.user_id = :user_id
|
||||||
ORDER BY created_at DESC
|
ORDER BY c.created_at DESC
|
||||||
""",
|
""",
|
||||||
{"user_id": user_id},
|
{"user_id": user_id},
|
||||||
DcaClient,
|
DcaClient,
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ class DcaClient(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
# Computed at SELECT time via LEFT JOIN on dca_lp. Lets the operator
|
||||||
|
# UI render "pending onboarding" badges and disable deposit creation
|
||||||
|
# without a second round-trip per row.
|
||||||
|
lp_onboarded: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UpdateDcaClientData(BaseModel):
|
class UpdateDcaClientData(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -141,18 +141,21 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
clientsTable: {
|
clientsTable: {
|
||||||
|
// Wallet / mode / autoforward dropped — those are LP-controlled
|
||||||
|
// via satmachineclient, not the operator's concern. `onboarded`
|
||||||
|
// surfaces the dca_lp existence flag (lp_onboarded) so operators
|
||||||
|
// can see at a glance which LPs still need to register before
|
||||||
|
// deposits can be recorded against them.
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
||||||
{name: 'username', label: 'LP', field: 'username', align: 'left'},
|
{name: 'username', label: 'LP', field: 'username', align: 'left'},
|
||||||
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
|
{name: 'onboarded', label: 'Onboarded', field: 'lp_onboarded', align: 'center'},
|
||||||
{name: 'dca_mode', label: 'Mode', field: 'dca_mode', align: 'left'},
|
|
||||||
{
|
{
|
||||||
name: 'remaining_balance',
|
name: 'remaining_balance',
|
||||||
label: 'Balance',
|
label: 'Balance',
|
||||||
field: 'remaining_balance',
|
field: 'remaining_balance',
|
||||||
align: 'right'
|
align: 'right'
|
||||||
},
|
},
|
||||||
{name: 'autoforward', label: '→', field: 'autoforward_enabled', align: 'center'},
|
|
||||||
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||||
],
|
],
|
||||||
|
|
@ -257,11 +260,24 @@ window.app = Vue.createApp({
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
depositClientOptions() {
|
depositClientOptions() {
|
||||||
|
// Annotate each LP option with onboarding state so the operator
|
||||||
|
// sees at-pick time which LPs can accept deposits. We don't hide
|
||||||
|
// un-onboarded LPs — the operator might want to know they exist
|
||||||
|
// and chase them — but submission is gated below by
|
||||||
|
// `selectedDepositClient.lp_onboarded`.
|
||||||
return this.clients.map(c => ({
|
return this.clients.map(c => ({
|
||||||
label: `${c.username || this.shortId(c.user_id)} @ ${this.machineNameById(c.machine_id)}`,
|
label:
|
||||||
value: c.id
|
`${c.username || this.shortId(c.user_id)} @ ` +
|
||||||
|
`${this.machineNameById(c.machine_id)}` +
|
||||||
|
(c.lp_onboarded ? '' : ' — pending onboarding'),
|
||||||
|
value: c.id,
|
||||||
|
disable: !c.lp_onboarded
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
selectedDepositClient() {
|
||||||
|
const id = this.depositDialog.data.client_id
|
||||||
|
return id ? this.clients.find(c => c.id === id) : null
|
||||||
|
},
|
||||||
worklistBuckets() {
|
worklistBuckets() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -559,9 +575,9 @@ window.app = Vue.createApp({
|
||||||
})
|
})
|
||||||
this._downloadCsv(
|
this._downloadCsv(
|
||||||
'clients.csv',
|
'clients.csv',
|
||||||
['id', 'machine_id', 'machine_name', 'user_id', 'wallet_id',
|
['id', 'machine_id', 'machine_name', 'user_id',
|
||||||
'username', 'dca_mode', 'status', 'autoforward_enabled',
|
'username', 'lp_onboarded', 'status',
|
||||||
'autoforward_ln_address', 'total_deposits', 'total_payments',
|
'total_deposits', 'total_payments',
|
||||||
'remaining_balance', 'balance_currency', 'created_at'],
|
'remaining_balance', 'balance_currency', 'created_at'],
|
||||||
rows
|
rows
|
||||||
)
|
)
|
||||||
|
|
@ -881,12 +897,7 @@ window.app = Vue.createApp({
|
||||||
id: client.id,
|
id: client.id,
|
||||||
machine_id: client.machine_id,
|
machine_id: client.machine_id,
|
||||||
user_id: client.user_id,
|
user_id: client.user_id,
|
||||||
wallet_id: client.wallet_id,
|
|
||||||
username: client.username || '',
|
username: client.username || '',
|
||||||
dca_mode: client.dca_mode,
|
|
||||||
fixed_mode_daily_limit: client.fixed_mode_daily_limit,
|
|
||||||
autoforward_enabled: !!client.autoforward_enabled,
|
|
||||||
autoforward_ln_address: client.autoforward_ln_address || '',
|
|
||||||
status: client.status
|
status: client.status
|
||||||
}
|
}
|
||||||
this.clientDialog.show = true
|
this.clientDialog.show = true
|
||||||
|
|
@ -1277,15 +1288,13 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
_emptyClientForm() {
|
_emptyClientForm() {
|
||||||
|
// Operator-side LP enrolment is just (machine, user, optional
|
||||||
|
// display name). Wallet / mode / autoforward are LP-controlled
|
||||||
|
// via satmachineclient — operator can't pick or change them.
|
||||||
return {
|
return {
|
||||||
machine_id: null,
|
machine_id: null,
|
||||||
user_id: '',
|
user_id: '',
|
||||||
wallet_id: '',
|
|
||||||
username: '',
|
username: '',
|
||||||
dca_mode: 'flow',
|
|
||||||
fixed_mode_daily_limit: null,
|
|
||||||
autoforward_enabled: false,
|
|
||||||
autoforward_ln_address: '',
|
|
||||||
status: 'active'
|
status: 'active'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1294,30 +1303,13 @@ window.app = Vue.createApp({
|
||||||
return {
|
return {
|
||||||
machine_id: d.machine_id,
|
machine_id: d.machine_id,
|
||||||
user_id: (d.user_id || '').trim(),
|
user_id: (d.user_id || '').trim(),
|
||||||
wallet_id: (d.wallet_id || '').trim(),
|
username: (d.username || '').trim() || null
|
||||||
username: (d.username || '').trim() || null,
|
|
||||||
dca_mode: d.dca_mode || 'flow',
|
|
||||||
fixed_mode_daily_limit:
|
|
||||||
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
|
|
||||||
? Number(d.fixed_mode_daily_limit) : null,
|
|
||||||
autoforward_enabled: !!d.autoforward_enabled,
|
|
||||||
autoforward_ln_address:
|
|
||||||
d.autoforward_enabled && d.autoforward_ln_address
|
|
||||||
? d.autoforward_ln_address.trim() : null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_cleanClientUpdate(d) {
|
_cleanClientUpdate(d) {
|
||||||
return {
|
return {
|
||||||
username: (d.username || '').trim() || null,
|
username: (d.username || '').trim() || null,
|
||||||
dca_mode: d.dca_mode,
|
|
||||||
fixed_mode_daily_limit:
|
|
||||||
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
|
|
||||||
? Number(d.fixed_mode_daily_limit) : null,
|
|
||||||
autoforward_enabled: !!d.autoforward_enabled,
|
|
||||||
autoforward_ln_address:
|
|
||||||
d.autoforward_enabled && d.autoforward_ln_address
|
|
||||||
? d.autoforward_ln_address.trim() : null,
|
|
||||||
status: d.status
|
status: d.status
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -222,13 +222,21 @@
|
||||||
<q-td key="username">
|
<q-td key="username">
|
||||||
<span v-text="props.row.username || shortId(props.row.user_id)"></span>
|
<span v-text="props.row.username || shortId(props.row.user_id)"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="wallet_id">
|
<q-td key="onboarded">
|
||||||
<code :style="{fontSize: '0.8em'}"
|
<q-icon v-if="props.row.lp_onboarded"
|
||||||
v-text="shortId(props.row.wallet_id)"></code>
|
name="check_circle" color="green" size="sm">
|
||||||
</q-td>
|
<q-tooltip>
|
||||||
<q-td key="dca_mode">
|
LP has onboarded via satmachineclient.
|
||||||
<q-badge :color="props.row.dca_mode === 'flow' ? 'blue' : 'purple'"
|
Deposits and DCA distributions can proceed.
|
||||||
:label="props.row.dca_mode"></q-badge>
|
</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>
|
||||||
<q-td key="remaining_balance" class="text-right">
|
<q-td key="remaining_balance" class="text-right">
|
||||||
<span v-if="clientBalances[props.row.id]"
|
<span v-if="clientBalances[props.row.id]"
|
||||||
|
|
@ -236,18 +244,6 @@
|
||||||
v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
|
v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
|
||||||
<span v-else :style="{opacity: 0.5}">…</span>
|
<span v-else :style="{opacity: 0.5}">…</span>
|
||||||
</q-td>
|
</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-td key="status">
|
||||||
<q-badge
|
<q-badge
|
||||||
:color="props.row.status === 'active' ? 'green' : 'grey'"
|
:color="props.row.status === 'active' ? 'green' : 'grey'"
|
||||||
|
|
@ -1128,6 +1124,17 @@
|
||||||
class="q-mb-md" dense outlined
|
class="q-mb-md" dense outlined
|
||||||
:rules="[v => !!v || 'Pick an LP']"></q-select>
|
: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"
|
<q-input v-model.number="depositDialog.data.amount"
|
||||||
label="Amount (fiat)"
|
label="Amount (fiat)"
|
||||||
type="number" step="0.01" min="0"
|
type="number" step="0.01" min="0"
|
||||||
|
|
@ -1148,6 +1155,7 @@
|
||||||
<q-btn color="primary"
|
<q-btn color="primary"
|
||||||
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
|
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
|
||||||
:loading="depositDialog.saving"
|
:loading="depositDialog.saving"
|
||||||
|
:disable="depositDialog.mode === 'add' && (!selectedDepositClient || !selectedDepositClient.lp_onboarded)"
|
||||||
@click="submitDeposit"></q-btn>
|
@click="submitDeposit"></q-btn>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -1195,9 +1203,10 @@
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
|
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
|
||||||
v-if="clientDialog.mode === 'add'">
|
v-if="clientDialog.mode === 'add'">
|
||||||
LPs receive DCA distributions proportional to their remaining
|
Enrol an LP at one of your machines. Wallet, DCA mode, and
|
||||||
balance. Each LP is scoped to a single machine; the same LP user
|
autoforward are configured by the LP themselves via the
|
||||||
can register at multiple machines as separate rows.
|
<b>satmachineclient</b> extension — you can't set them here.
|
||||||
|
Deposits are refused until the LP has registered.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
|
@ -1217,38 +1226,9 @@
|
||||||
class="q-mb-md" dense outlined
|
class="q-mb-md" dense outlined
|
||||||
:rules="[v => !!v || 'Required']"></q-input>
|
:rules="[v => !!v || 'Required']"></q-input>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<q-input v-model="clientDialog.data.username"
|
<q-input v-model="clientDialog.data.username"
|
||||||
label="Display name (optional)"
|
label="Display name (optional)"
|
||||||
class="q-mb-md" dense outlined></q-input>
|
hint="Operator-facing label only; doesn't affect distribution"
|
||||||
|
|
||||||
<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-select>
|
|
||||||
|
|
||||||
<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-input>
|
|
||||||
|
|
||||||
<q-toggle v-model="clientDialog.data.autoforward_enabled"
|
|
||||||
label="Auto-forward DCA to external LN address"
|
|
||||||
class="q-mb-md"></q-toggle>
|
|
||||||
|
|
||||||
<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-input>
|
class="q-mb-md" dense outlined></q-input>
|
||||||
|
|
||||||
<q-select v-if="clientDialog.mode === 'edit'"
|
<q-select v-if="clientDialog.mode === 'edit'"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue