feat(ui): super-user payroll console

The page the whole extension exists to be driven from: pick a user, pick
which of their wallets to pay into, set amount, currency, frequency, start
date and how many payments — plus the ledger, filters and a CSV button.

Notes for review:

- The employee-wallet picker only offers wallets belonging to the selected
  employee. The API rejects anything else, so this is about not presenting
  the mistake rather than about enforcement.
- The dialog draws a live calendar from whatever is currently typed. A
  wrong start date or frequency is cheapest to catch before saving, which
  is what the preview endpoint was for.
- "Next payday" comes from the schedule endpoint per row rather than being
  computed in JS. A payday this page derived for itself could disagree with
  the one the scheduler will actually use, and month-end is exactly where
  that would happen.
- The destructive actions say what they do: resume warns that missed
  paydays are written off, delete says the schedule position goes with the
  row and points at cancel instead, pay-now says the period is consumed
  even if its payday has not arrived.
- A refused pay-now surfaces the ledger row's reason with a longer toast —
  triggering a payout by hand is precisely when you want to know why it
  did not land.

Quasar UMD rules honoured: no self-closing tags anywhere in the template,
`${ }` delimiters so Jinja never sees a moustache, and `:style` bindings
instead of a <style> block, since LNbits themes override typography
utilities with !important.

The page route is gated on super_user as well, via check_user_exists plus
an explicit flag check — the template needs the full User for its wallet
picker, which check_super_user does not return. It is a UX nicety; the API
behind it is gated independently and does not trust this route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
Padreug 2026-08-31 13:56:16 +02:00
commit 23bc54f558
4 changed files with 901 additions and 0 deletions

View file

@ -4,6 +4,7 @@ from fastapi import APIRouter
from loguru import logger
from .crud import db
from .views import payroll_generic_router
from .views_api import payroll_api_router, payroll_employee_router
payroll_static_files = [
@ -14,6 +15,7 @@ payroll_static_files = [
]
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
payroll_ext.include_router(payroll_generic_router)
payroll_ext.include_router(payroll_api_router)
payroll_ext.include_router(payroll_employee_router)

491
static/js/index.js Normal file
View file

@ -0,0 +1,491 @@
// Payroll operator console — LNbits page shell (Vue 3 + Quasar 2 UMD, no
// build step). Gotchas honored: no self-closing tags in the template,
// `${ }` interpolation so Jinja never sees a moustache, and `:style`
// bindings rather than <style> blocks for typography, which LNbits themes
// override with !important. See workspace CLAUDE.md.
const API = '/payroll/api/v1'
const FREQUENCIES = [
{value: 'daily', label: 'Daily'},
{value: 'weekly', label: 'Weekly'},
{value: 'biweekly', label: 'Every two weeks'},
{value: 'monthly', label: 'Monthly'},
{value: 'quarterly', label: 'Quarterly'},
{value: 'yearly', label: 'Yearly'}
]
const STATUS_COLOUR = {
active: 'positive',
paused: 'warning',
completed: 'grey',
cancelled: 'grey-7',
paid: 'positive',
skipped: 'grey',
failed: 'negative'
}
function today() {
return new Date().toISOString().slice(0, 10)
}
function emptyContract() {
return {
employee_id: null,
employee_wallet: null,
source_wallet: null,
amount: null,
currency: 'sat',
frequency: 'monthly',
start_date: today(),
// The form models duration as a toggle plus a count, because
// "open-ended" and "12 payments" are the two things an operator
// actually says; the API takes total_periods = null for the former.
open_ended: false,
total_periods: 12,
label: '',
memo: '',
backfill: false
}
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data() {
return {
tab: 'contracts',
contracts: [],
contractsLoading: false,
payouts: [],
payoutsLoading: false,
users: [],
currencyOptions: ['sat'],
currencyFilter: ['sat'],
frequencyOptions: FREQUENCIES,
contractDialog: {show: false, data: emptyContract(), saving: false},
// Live calendar of whatever is currently typed into the dialog. The
// cheapest moment to notice a wrong start date or frequency is before
// anything is saved.
preview: null,
editDialog: {show: false, id: null, data: {}},
scheduleDialog: {show: false, contract: null, preview: null},
ledgerFilter: {contract_id: null, status: null, since: '', until: ''},
contractsColumns: [
{
name: 'employee',
label: 'Employee',
align: 'left',
field: row => row.label || row.employee_username || row.employee_id
},
{
name: 'amount',
label: 'Amount',
align: 'right',
field: row => `${row.amount} ${row.currency}`
},
{name: 'frequency', label: 'Every', align: 'left', field: 'frequency'},
{name: 'next', label: 'Next payday', align: 'left', field: 'id'},
{name: 'progress', label: 'Paid', align: 'center', field: 'id'},
{name: 'status', label: 'Status', align: 'center', field: 'status'},
{name: 'actions', label: '', align: 'right', field: 'id'}
],
payoutsColumns: [
{name: 'payday', label: 'Payday', align: 'left', field: 'payday'},
{name: 'status', label: 'Status', align: 'center', field: 'status'},
{
name: 'amount',
label: 'Amount',
align: 'right',
field: row =>
row.amount_msat === null
? '—'
: `${Math.round(row.amount_msat / 1000)} sat`
},
{
name: 'instruction',
label: 'Contract terms',
align: 'right',
field: row => `${row.amount} ${row.currency}`
},
{
name: 'contract',
label: 'Contract',
align: 'left',
field: 'contract_id'
},
{name: 'attempt', label: 'Try', align: 'center', field: 'attempt'},
{name: 'detail', label: 'Detail', align: 'left', field: 'detail'}
]
}
},
computed: {
userOptions() {
return this.users.map(u => ({
label: u.username || u.email || u.id,
value: u.id,
caption: `${u.wallets.length} wallet(s)`
}))
},
// Wallets belonging to the employee currently selected in the dialog.
// The API rejects a wallet that is not theirs, so narrowing the picker
// is about not offering the mistake in the first place.
employeeWalletOptions() {
const user = this.users.find(
u => u.id === this.contractDialog.data.employee_id
)
if (!user) return []
return user.wallets.map(w => ({
label: `${w.name} — ${Math.round(w.balance_msat / 1000)} sat`,
value: w.id
}))
},
sourceWalletOptions() {
return (this.g.user.wallets || []).map(w => ({
label: w.name,
value: w.id
}))
},
contractOptions() {
return this.contracts.map(c => ({
label: `${c.label || c.employee_username || c.id} (${c.id})`,
value: c.id
}))
},
canSaveContract() {
const d = this.contractDialog.data
return !!(
d.employee_id &&
d.employee_wallet &&
d.source_wallet &&
d.amount > 0 &&
d.start_date
)
},
ledgerCsvHref() {
const f = this.ledgerFilter
const params = new URLSearchParams()
if (f.contract_id) params.append('contract_id', f.contract_id)
if (f.status) params.append('status', f.status)
if (f.since) params.append('since', f.since)
if (f.until) params.append('until', f.until)
return `${API}/payouts.csv?${params.toString()}`
}
},
watch: {
// Redraw the calendar whenever a term that shapes it changes.
'contractDialog.data.start_date': 'refreshPreview',
'contractDialog.data.frequency': 'refreshPreview',
'contractDialog.data.total_periods': 'refreshPreview',
'contractDialog.data.open_ended': 'refreshPreview'
},
methods: {
statusColour(status) {
return STATUS_COLOUR[status] || 'grey'
},
_err(err, fallback) {
if (err && err.response) return LNbits.utils.notifyApiError(err)
Quasar.Notify.create({type: 'negative', message: fallback})
},
// --- loading ----------------------------------------------------------
async getUsers() {
try {
const {data} = await LNbits.api.request('GET', `${API}/users`)
this.users = data
} catch (err) {
this._err(err, 'Could not load the account directory')
}
},
async getCurrencies() {
try {
const {data} = await LNbits.api.request('GET', `${API}/currencies`)
this.currencyOptions = data
this.currencyFilter = data
} catch (err) {
this._err(err, 'Could not load currencies')
}
},
filterCurrencies(needle, update) {
update(() => {
const term = (needle || '').toLowerCase()
this.currencyFilter = this.currencyOptions.filter(c =>
c.toLowerCase().includes(term)
)
})
},
async getContracts() {
this.contractsLoading = true
try {
const {data} = await LNbits.api.request('GET', `${API}/contracts`)
this.contracts = data
// Next payday is derived server-side; fetch it per row so the table
// never shows a date this page computed for itself.
await Promise.all(this.contracts.map(c => this.attachNextPayday(c)))
} catch (err) {
this._err(err, 'Could not load contracts')
} finally {
this.contractsLoading = false
}
},
async attachNextPayday(contract) {
try {
const {data} = await LNbits.api.request(
'GET',
`${API}/contracts/${contract.id}/schedule?count=1`
)
contract.next_payday = data.paydays[0] || null
contract.ends_on = data.ends_on
} catch (err) {
contract.next_payday = null
}
},
async getPayouts() {
this.payoutsLoading = true
const f = this.ledgerFilter
const params = new URLSearchParams()
if (f.contract_id) params.append('contract_id', f.contract_id)
if (f.status) params.append('status', f.status)
if (f.since) params.append('since', f.since)
if (f.until) params.append('until', f.until)
try {
const {data} = await LNbits.api.request(
'GET',
`${API}/payouts?${params.toString()}`
)
this.payouts = data
} catch (err) {
this._err(err, 'Could not load the payout ledger')
} finally {
this.payoutsLoading = false
}
},
// --- create / edit ----------------------------------------------------
openContractDialog() {
this.contractDialog.data = emptyContract()
this.preview = null
this.contractDialog.show = true
this.refreshPreview()
},
async refreshPreview() {
const d = this.contractDialog.data
if (!d.start_date || !/^\d{4}-\d{2}-\d{2}$/.test(d.start_date)) {
this.preview = null
return
}
try {
const {data} = await LNbits.api.request(
'POST',
`${API}/schedule/preview`,
null,
{
start_date: d.start_date,
frequency: d.frequency,
total_periods: d.open_ended ? null : Number(d.total_periods),
count: 6
}
)
this.preview = data
} catch (err) {
this.preview = null
}
},
async saveContract() {
const d = this.contractDialog.data
this.contractDialog.saving = true
try {
await LNbits.api.request('POST', `${API}/contracts`, null, {
employee_id: d.employee_id,
employee_wallet: d.employee_wallet,
source_wallet: d.source_wallet,
amount: Number(d.amount),
currency: d.currency,
frequency: d.frequency,
start_date: d.start_date,
total_periods: d.open_ended ? null : Number(d.total_periods),
label: d.label,
memo: d.memo,
backfill: d.backfill
})
this.contractDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Payroll line created'})
this.getContracts()
} catch (err) {
this._err(err, 'Could not create the payroll line')
} finally {
this.contractDialog.saving = false
}
},
openEditDialog(contract) {
this.editDialog = {
show: true,
id: contract.id,
data: {
amount: contract.amount,
currency: contract.currency,
frequency: contract.frequency,
open_ended: contract.total_periods === null,
total_periods: contract.total_periods,
label: contract.label,
memo: contract.memo
}
}
},
async saveEdit() {
const d = this.editDialog.data
try {
await LNbits.api.request(
'PUT',
`${API}/contracts/${this.editDialog.id}`,
null,
{
amount: Number(d.amount),
currency: d.currency,
frequency: d.frequency,
total_periods: d.open_ended ? null : Number(d.total_periods),
label: d.label,
memo: d.memo
}
)
this.editDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Terms updated'})
this.getContracts()
} catch (err) {
this._err(err, 'Could not update the contract')
}
},
// --- lifecycle --------------------------------------------------------
async transition(contract, action, params = '') {
try {
await LNbits.api.request(
'POST',
`${API}/contracts/${contract.id}/${action}${params}`
)
this.getContracts()
} catch (err) {
this._err(err, `Could not ${action} the contract`)
}
},
resumeContract(contract) {
LNbits.utils
.confirmDialog(
'Resume this contract? The paydays missed while it was paused ' +
'are written off — they are not paid retroactively.'
)
.onOk(() => this.transition(contract, 'resume'))
},
cancelContract(contract) {
LNbits.utils
.confirmDialog(
'Cancel this contract for good? It stops paying but keeps its ' +
'history. This cannot be undone.'
)
.onOk(() => this.transition(contract, 'cancel'))
},
payNow(contract) {
LNbits.utils
.confirmDialog(
`Pay the next period of "${contract.label || contract.id}" now? ` +
'It is consumed even if its payday has not arrived yet.'
)
.onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${API}/contracts/${contract.id}/pay-now`
)
// A refused payout still returns a ledger row — surfacing its
// reason is the whole point of triggering one by hand.
Quasar.Notify.create({
type: data.status === 'paid' ? 'positive' : 'negative',
message:
data.status === 'paid'
? `Paid ${Math.round(data.amount_msat / 1000)} sat`
: `Payout ${data.status}: ${data.detail}`,
timeout: data.status === 'paid' ? 3000 : 8000
})
this.getContracts()
this.getPayouts()
} catch (err) {
this._err(err, 'Could not pay now')
}
})
},
deleteContract(contract) {
LNbits.utils
.confirmDialog(
'Delete this contract row outright? Its schedule position goes ' +
'with it. To stop a running payroll, cancel it instead — that ' +
'keeps the record.'
)
.onOk(async () => {
try {
await LNbits.api.request('DELETE', `${API}/contracts/${contract.id}`)
this.getContracts()
} catch (err) {
this._err(err, 'Could not delete the contract')
}
})
},
async showSchedule(contract) {
this.scheduleDialog = {show: true, contract, preview: null}
try {
const {data} = await LNbits.api.request(
'GET',
`${API}/contracts/${contract.id}/schedule?count=24`
)
this.scheduleDialog.preview = data
} catch (err) {
this._err(err, 'Could not load the schedule')
}
},
showLedgerFor(contract) {
this.ledgerFilter.contract_id = contract.id
this.tab = 'ledger'
this.getPayouts()
}
},
created() {
this.getUsers()
this.getCurrencies()
this.getContracts()
this.getPayouts()
}
})

View file

@ -0,0 +1,373 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('payroll/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12">
<div class="row items-center q-mb-md">
<div class="col">
<h4 class="q-my-none">Payroll</h4>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Recurring payouts from a wallet you control into an employee's wallet.
</p>
</div>
<div class="col-auto">
<q-btn unelevated color="primary" icon="add" label="New payroll line"
@click="openContractDialog"></q-btn>
</div>
</div>
<q-card>
<q-tabs v-model="tab" align="left"
active-color="primary" indicator-color="primary" class="text-grey">
<q-tab name="contracts" icon="event_repeat" label="Contracts"></q-tab>
<q-tab name="ledger" icon="receipt_long" label="Payout ledger"></q-tab>
</q-tabs>
<q-separator></q-separator>
<q-tab-panels v-model="tab" animated>
<!-- ------------------------------------------------ contracts -->
<q-tab-panel name="contracts">
<q-table flat dense :rows="contracts" :columns="contractsColumns"
row-key="id" :loading="contractsLoading"
:pagination="{rowsPerPage: 25}">
<template v-slot:body-cell-next="props">
<q-td :props="props">
<span v-if="props.row.next_payday">
${ props.row.next_payday }
</span>
<span v-else :style="{opacity: 0.5}">—</span>
</q-td>
</template>
<template v-slot:body-cell-progress="props">
<q-td :props="props">
${ props.row.periods_done }<span :style="{opacity: 0.5}">
/ ${ props.row.total_periods === null ? '∞' : props.row.total_periods }
</span>
</q-td>
</template>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense square :color="statusColour(props.row.status)"
text-color="white" :label="props.row.status"></q-chip>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="q-gutter-xs">
<q-btn flat dense round icon="event" size="sm"
@click="showSchedule(props.row)">
<q-tooltip>Upcoming paydays</q-tooltip>
</q-btn>
<q-btn flat dense round icon="receipt_long" size="sm"
@click="showLedgerFor(props.row)">
<q-tooltip>Payout history</q-tooltip>
</q-btn>
<q-btn flat dense round icon="edit" size="sm"
@click="openEditDialog(props.row)">
<q-tooltip>Edit terms</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'active'"
flat dense round icon="bolt" size="sm" color="primary"
@click="payNow(props.row)">
<q-tooltip>Pay the next period now</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'active'"
flat dense round icon="pause" size="sm"
@click="transition(props.row, 'pause')">
<q-tooltip>Pause</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'paused'"
flat dense round icon="play_arrow" size="sm" color="positive"
@click="resumeContract(props.row)">
<q-tooltip>Resume</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'active' || props.row.status === 'paused'"
flat dense round icon="block" size="sm" color="warning"
@click="cancelContract(props.row)">
<q-tooltip>Cancel for good</q-tooltip>
</q-btn>
<q-btn flat dense round icon="delete" size="sm" color="negative"
@click="deleteContract(props.row)">
<q-tooltip>Delete the row and its schedule position</q-tooltip>
</q-btn>
</q-td>
</template>
<template v-slot:no-data>
<div class="full-width row justify-center q-pa-lg"
:style="{opacity: 0.6}">
No payroll lines yet.
</div>
</template>
</q-table>
</q-tab-panel>
<!-- --------------------------------------------------- ledger -->
<q-tab-panel name="ledger">
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-12 col-md-4">
<q-select outlined dense clearable v-model="ledgerFilter.contract_id"
:options="contractOptions" emit-value map-options
label="Contract" @update:model-value="getPayouts"></q-select>
</div>
<div class="col-6 col-md-2">
<q-select outlined dense clearable v-model="ledgerFilter.status"
:options="['paid', 'failed', 'skipped']"
label="Status" @update:model-value="getPayouts"></q-select>
</div>
<div class="col-6 col-md-2">
<q-input outlined dense v-model="ledgerFilter.since"
mask="####-##-##" label="Payday from" @blur="getPayouts"></q-input>
</div>
<div class="col-6 col-md-2">
<q-input outlined dense v-model="ledgerFilter.until"
mask="####-##-##" label="Payday to" @blur="getPayouts"></q-input>
</div>
<div class="col-6 col-md-2">
<q-btn outline color="primary" icon="download" label="CSV"
type="a" :href="ledgerCsvHref"></q-btn>
</div>
</div>
<q-table flat dense :rows="payouts" :columns="payoutsColumns"
row-key="id" :loading="payoutsLoading"
:pagination="{rowsPerPage: 25}">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense square :color="statusColour(props.row.status)"
text-color="white" :label="props.row.status"></q-chip>
</q-td>
</template>
<template v-slot:body-cell-detail="props">
<q-td :props="props">
<span :style="{opacity: 0.75, fontSize: '0.85em'}">
${ props.row.detail }
</span>
</q-td>
</template>
<template v-slot:no-data>
<div class="full-width row justify-center q-pa-lg"
:style="{opacity: 0.6}">
Nothing paid yet. Failures and skips land here too.
</div>
</template>
</q-table>
</q-tab-panel>
</q-tab-panels>
</q-card>
</div>
</div>
<!-- ================================================ new contract -->
<q-dialog v-model="contractDialog.show">
<q-card style="width: 640px; max-width: 95vw">
<q-card-section>
<div class="text-h6">New payroll line</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-select outlined dense v-model="contractDialog.data.employee_id"
:options="userOptions" emit-value map-options
label="Employee *"
@update:model-value="contractDialog.data.employee_wallet = null">
</q-select>
<q-select outlined dense v-model="contractDialog.data.employee_wallet"
:options="employeeWalletOptions" emit-value map-options
:disable="!contractDialog.data.employee_id"
label="Pay into (their wallet) *"
hint="Only wallets belonging to the selected employee">
</q-select>
<q-select outlined dense v-model="contractDialog.data.source_wallet"
:options="sourceWalletOptions" emit-value map-options
label="Pay from (your wallet) *"></q-select>
<div class="row q-col-gutter-sm">
<div class="col-7">
<q-input outlined dense type="number" step="any"
v-model.number="contractDialog.data.amount" label="Amount *"></q-input>
</div>
<div class="col-5">
<q-select outlined dense use-input input-debounce="0"
v-model="contractDialog.data.currency"
:options="currencyFilter" @filter="filterCurrencies"
label="Currency"
hint="Fiat is priced in sats at each payout"></q-select>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-select outlined dense v-model="contractDialog.data.frequency"
:options="frequencyOptions" emit-value map-options
label="Frequency"></q-select>
</div>
<div class="col-6">
<q-input outlined dense v-model="contractDialog.data.start_date"
mask="####-##-##" label="First payday *"
hint="Anchors every later payday">
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale">
<q-date v-model="contractDialog.data.start_date"
mask="YYYY-MM-DD"></q-date>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
</div>
<div class="row items-center q-col-gutter-sm">
<div class="col-auto">
<q-toggle v-model="contractDialog.data.open_ended"
label="Open-ended"></q-toggle>
</div>
<div class="col">
<q-input outlined dense type="number"
v-model.number="contractDialog.data.total_periods"
:disable="contractDialog.data.open_ended"
label="Number of payments"></q-input>
</div>
</div>
<q-input outlined dense v-model="contractDialog.data.label"
label="Label" hint="Shown in this table, e.g. 'Alice — dev retainer'"></q-input>
<q-input outlined dense v-model="contractDialog.data.memo"
label="Payment memo" hint="What the employee sees on the payment"></q-input>
<q-toggle v-model="contractDialog.data.backfill"
label="Pay paydays that already passed">
<q-tooltip>
Off by default: a back-dated start normally means "we pay on the
15th", not "send five months of back-pay now".
</q-tooltip>
</q-toggle>
<q-separator></q-separator>
<div v-if="preview">
<div class="text-subtitle2 q-mb-xs">Next paydays</div>
<div class="row q-gutter-xs">
<q-chip v-for="d in preview.paydays" :key="d" dense square outline
:label="d"></q-chip>
<q-chip v-if="preview.truncated" dense square outline label="…"></q-chip>
</div>
<div class="q-mt-sm" :style="{opacity: 0.7, fontSize: '0.9em'}">
<span v-if="preview.ends_on">
${ preview.total_periods } payment(s), ending ${ preview.ends_on }.
</span>
<span v-else>Open-ended — pays until paused or cancelled.</span>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Create" :loading="contractDialog.saving"
:disable="!canSaveContract" @click="saveContract"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- ======================================================= edit -->
<q-dialog v-model="editDialog.show">
<q-card style="width: 480px; max-width: 95vw">
<q-card-section>
<div class="text-h6">Edit terms</div>
<div class="text-caption" :style="{opacity: 0.7}">
The employee, the source wallet and the start date are fixed. Changing
them would rewrite what past payouts were for. Cancel and re-create
instead.
</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<div class="row q-col-gutter-sm">
<div class="col-7">
<q-input outlined dense type="number" step="any"
v-model.number="editDialog.data.amount" label="Amount"></q-input>
</div>
<div class="col-5">
<q-select outlined dense use-input input-debounce="0"
v-model="editDialog.data.currency"
:options="currencyFilter" @filter="filterCurrencies"
label="Currency"></q-select>
</div>
</div>
<q-select outlined dense v-model="editDialog.data.frequency"
:options="frequencyOptions" emit-value map-options
label="Frequency"></q-select>
<div class="row items-center q-col-gutter-sm">
<div class="col-auto">
<q-toggle v-model="editDialog.data.open_ended" label="Open-ended"></q-toggle>
</div>
<div class="col">
<q-input outlined dense type="number"
v-model.number="editDialog.data.total_periods"
:disable="editDialog.data.open_ended"
label="Number of payments"></q-input>
</div>
</div>
<q-input outlined dense v-model="editDialog.data.label" label="Label"></q-input>
<q-input outlined dense v-model="editDialog.data.memo"
label="Payment memo"></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" @click="saveEdit"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =================================================== schedule -->
<q-dialog v-model="scheduleDialog.show">
<q-card style="width: 420px; max-width: 95vw">
<q-card-section>
<div class="text-h6">Upcoming paydays</div>
<div v-if="scheduleDialog.contract" class="text-caption"
:style="{opacity: 0.7}">
${ scheduleDialog.contract.label || scheduleDialog.contract.employee_username }
— ${ scheduleDialog.contract.amount } ${ scheduleDialog.contract.currency }
${ scheduleDialog.contract.frequency }
</div>
</q-card-section>
<q-card-section>
<div v-if="scheduleDialog.preview">
<div class="row q-gutter-xs">
<q-chip v-for="d in scheduleDialog.preview.paydays" :key="d"
dense square outline :label="d"></q-chip>
</div>
<div v-if="!scheduleDialog.preview.paydays.length" :style="{opacity: 0.6}">
No paydays left.
</div>
<div class="q-mt-sm" :style="{opacity: 0.7, fontSize: '0.9em'}">
<span v-if="scheduleDialog.preview.ends_on">
Ends ${ scheduleDialog.preview.ends_on }.
</span>
<span v-else>Open-ended.</span>
</div>
</div>
<q-spinner v-else color="primary"></q-spinner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" v-close-popup></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}

35
views.py Normal file
View file

@ -0,0 +1,35 @@
"""Frontend route — serves the operator console.
Gated the same way the API is: super user only. `check_user_exists` is used
rather than `check_super_user` because the template needs the full `User`
(its wallets populate the source-wallet picker) and `check_super_user`
resolves to an `Account`; the privilege check is then made explicitly off
`user.super_user`. Rejecting here is a UX nicety — the API behind the page
is gated independently and does not trust this route.
"""
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
payroll_generic_router = APIRouter()
def payroll_renderer():
return template_renderer(["payroll/templates"])
@payroll_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
if not user.super_user:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Payroll is restricted to the instance super user.",
)
return payroll_renderer().TemplateResponse(
"payroll/index.html", {"request": request, "user": user.json()}
)