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

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()
}
})