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:
parent
4b6d647d23
commit
23bc54f558
4 changed files with 901 additions and 0 deletions
491
static/js/index.js
Normal file
491
static/js/index.js
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue