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
|
|
@ -4,6 +4,7 @@ from fastapi import APIRouter
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import db
|
from .crud import db
|
||||||
|
from .views import payroll_generic_router
|
||||||
from .views_api import payroll_api_router, payroll_employee_router
|
from .views_api import payroll_api_router, payroll_employee_router
|
||||||
|
|
||||||
payroll_static_files = [
|
payroll_static_files = [
|
||||||
|
|
@ -14,6 +15,7 @@ payroll_static_files = [
|
||||||
]
|
]
|
||||||
|
|
||||||
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
|
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_api_router)
|
||||||
payroll_ext.include_router(payroll_employee_router)
|
payroll_ext.include_router(payroll_employee_router)
|
||||||
|
|
||||||
|
|
|
||||||
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
373
templates/payroll/index.html
Normal file
373
templates/payroll/index.html
Normal 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
35
views.py
Normal 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()}
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue