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 .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
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