feat: operator admin UI (rooms, bookings, calendar blocks, settings)
Replaces the placeholder page with a real 4-tab admin (Vue 3 + Quasar 2 UMD,
no build). Rooms table with create/edit dialog + publish-toggle + delete;
per-room bookings view; per-room calendar blocks add/delete; operator
settings form (identity, relays, hold, deposit, times, policy).
UMD gotchas honored: no self-closing tags, ${ } interpolation, :style for
typography, static_url_for(path=...). Follows the spirekeeper admin pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
parent
f715d728ef
commit
e6973fa192
2 changed files with 487 additions and 16 deletions
|
|
@ -1,15 +1,247 @@
|
|||
// Chatelet operator admin page — placeholder.
|
||||
// Quasar 2 + Vue 3 as UMD globals (no build step). Remember: no
|
||||
// self-closing tags in UMD templates, and use :style bindings (not <style>
|
||||
// blocks) for per-element typography overrides on LNbits pages.
|
||||
// Chatelet operator admin — LNbits page shell (Vue 3 + Quasar 2 UMD, no build).
|
||||
// Gotchas honored: no self-closing tags in the template, `${ }` interpolation,
|
||||
// `:style` bindings for typography. See workspace CLAUDE.md.
|
||||
|
||||
const API = '/chatelet/api/v1'
|
||||
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin],
|
||||
mixins: [windowMixin],
|
||||
delimiters: ['${', '}'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
tab: 'rooms',
|
||||
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomDialog: {show: false, data: {}},
|
||||
currencyOptions: ['sat', 'EUR', 'USD', 'GBP', 'CHF'],
|
||||
frequencyOptions: ['night', 'week', 'month'],
|
||||
|
||||
bookingsRoomId: null,
|
||||
bookings: [],
|
||||
|
||||
blocksRoomId: null,
|
||||
blocks: [],
|
||||
blockForm: {start_date: '', end_date: '', reason: ''},
|
||||
|
||||
settings: {},
|
||||
settingsLoading: false,
|
||||
|
||||
roomsColumns: [
|
||||
{name: 'title', label: 'Room', field: 'title', align: 'left'},
|
||||
{
|
||||
name: 'price', label: 'Price', align: 'left',
|
||||
field: row => `${row.price_amount} ${row.price_currency} / ${row.price_frequency}`
|
||||
},
|
||||
{name: 'max_guests', label: 'Max guests', field: 'max_guests', align: 'center'},
|
||||
{name: 'status', label: 'Status', field: 'status', align: 'center'},
|
||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||
],
|
||||
bookingsColumns: [
|
||||
{name: 'check_in', label: 'Check-in', field: 'check_in', align: 'left'},
|
||||
{name: 'check_out', label: 'Check-out', field: 'check_out', align: 'left'},
|
||||
{name: 'nights', label: 'Nights', field: 'nights', align: 'center'},
|
||||
{name: 'num_guests', label: 'Guests', field: 'num_guests', align: 'center'},
|
||||
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||
{name: 'amount_sat', label: 'Sats', field: 'amount_sat', align: 'right'},
|
||||
{
|
||||
name: 'guest', label: 'Guest', align: 'left',
|
||||
field: row => (row.guest_pubkey || '').slice(0, 12) + '…'
|
||||
}
|
||||
],
|
||||
blocksColumns: [
|
||||
{name: 'start_date', label: 'From', field: 'start_date', align: 'left'},
|
||||
{name: 'end_date', label: 'To (exclusive)', field: 'end_date', align: 'left'},
|
||||
{name: 'reason', label: 'Reason', field: 'reason', align: 'left'},
|
||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||
]
|
||||
}
|
||||
},
|
||||
// TODO: wire GET /chatelet/api/v1/rooms, room CRUD, calendar, bookings.
|
||||
|
||||
computed: {
|
||||
adminkey() {
|
||||
const w = this.g.user.wallets[0]
|
||||
return w && w.adminkey
|
||||
},
|
||||
roomOptions() {
|
||||
return this.rooms.map(r => ({label: r.title, value: r.id}))
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
_err(err, fallback) {
|
||||
const msg = (err && err.response && err.response.data && err.response.data.detail)
|
||||
|| (err && err.message) || fallback
|
||||
Quasar.Notify.create({type: 'negative', message: msg, timeout: 5000})
|
||||
},
|
||||
statusColor(status) {
|
||||
return {
|
||||
active: 'green', inactive: 'grey',
|
||||
held: 'orange', awaiting_payment: 'amber', confirmed: 'green',
|
||||
checked_in: 'teal', completed: 'blue-grey',
|
||||
cancelled: 'red', declined: 'red', expired: 'grey'
|
||||
}[status] || 'grey'
|
||||
},
|
||||
|
||||
// --- rooms ---
|
||||
async getRooms() {
|
||||
this.roomsLoading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `${API}/rooms`, this.adminkey)
|
||||
this.rooms = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load rooms')
|
||||
} finally {
|
||||
this.roomsLoading = false
|
||||
}
|
||||
},
|
||||
newRoom() {
|
||||
this.roomDialog = {
|
||||
show: true,
|
||||
data: {
|
||||
price_currency: 'sat', price_frequency: 'night',
|
||||
max_guests: 2, min_nights: 1, amenities: [], images: []
|
||||
}
|
||||
}
|
||||
},
|
||||
editRoom(room) {
|
||||
this.roomDialog = {show: true, data: JSON.parse(JSON.stringify(room))}
|
||||
},
|
||||
async saveRoom() {
|
||||
const d = this.roomDialog.data
|
||||
const body = {
|
||||
title: d.title,
|
||||
description: d.description || '',
|
||||
price_amount: Number(d.price_amount),
|
||||
price_currency: d.price_currency,
|
||||
price_frequency: d.price_frequency || 'night',
|
||||
max_guests: Number(d.max_guests),
|
||||
min_nights: Number(d.min_nights),
|
||||
amenities: d.amenities || [],
|
||||
location: d.location || '',
|
||||
geohash: d.geohash || '',
|
||||
images: d.images || [],
|
||||
checkin_instructions: d.checkin_instructions || ''
|
||||
}
|
||||
try {
|
||||
if (d.id) {
|
||||
await LNbits.api.request('PUT', `${API}/rooms/${d.id}`, this.adminkey, body)
|
||||
} else {
|
||||
await LNbits.api.request('POST', `${API}/rooms`, this.adminkey, body)
|
||||
}
|
||||
this.roomDialog.show = false
|
||||
Quasar.Notify.create({type: 'positive', message: 'Room saved'})
|
||||
this.getRooms()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not save room')
|
||||
}
|
||||
},
|
||||
deleteRoom(room) {
|
||||
LNbits.utils
|
||||
.confirmDialog(`Delete room "${room.title}"? Its bookings + blocks go too.`)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request('DELETE', `${API}/rooms/${room.id}`, this.adminkey)
|
||||
this.getRooms()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not delete room')
|
||||
}
|
||||
})
|
||||
},
|
||||
async setPublished(room, on) {
|
||||
const path = on ? 'publish' : 'unpublish'
|
||||
try {
|
||||
await LNbits.api.request('POST', `${API}/rooms/${room.id}/${path}`, this.adminkey)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: on ? 'Published to relays' : 'Unpublished'
|
||||
})
|
||||
this.getRooms()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not change publish state')
|
||||
}
|
||||
},
|
||||
|
||||
// --- bookings ---
|
||||
async loadBookings() {
|
||||
if (!this.bookingsRoomId) return
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET', `${API}/rooms/${this.bookingsRoomId}/bookings`, this.adminkey
|
||||
)
|
||||
this.bookings = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load bookings')
|
||||
}
|
||||
},
|
||||
|
||||
// --- blocks ---
|
||||
async loadBlocks() {
|
||||
if (!this.blocksRoomId) return
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET', `${API}/rooms/${this.blocksRoomId}/blocks`, this.adminkey
|
||||
)
|
||||
this.blocks = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load blocks')
|
||||
}
|
||||
},
|
||||
async addBlock() {
|
||||
if (!this.blocksRoomId) return
|
||||
const body = {
|
||||
room_id: this.blocksRoomId,
|
||||
start_date: this.blockForm.start_date,
|
||||
end_date: this.blockForm.end_date,
|
||||
reason: this.blockForm.reason || ''
|
||||
}
|
||||
try {
|
||||
await LNbits.api.request('POST', `${API}/blocks`, this.adminkey, body)
|
||||
this.blockForm = {start_date: '', end_date: '', reason: ''}
|
||||
Quasar.Notify.create({type: 'positive', message: 'Dates blocked'})
|
||||
this.loadBlocks()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not add block')
|
||||
}
|
||||
},
|
||||
async deleteBlock(block) {
|
||||
try {
|
||||
await LNbits.api.request('DELETE', `${API}/blocks/${block.id}`, this.adminkey)
|
||||
this.loadBlocks()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not delete block')
|
||||
}
|
||||
},
|
||||
|
||||
// --- settings ---
|
||||
async getSettings() {
|
||||
this.settingsLoading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `${API}/settings`, this.adminkey)
|
||||
this.settings = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load settings')
|
||||
} finally {
|
||||
this.settingsLoading = false
|
||||
}
|
||||
},
|
||||
async saveSettings() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT', `${API}/settings`, this.adminkey, this.settings
|
||||
)
|
||||
this.settings = data
|
||||
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not save settings')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.getRooms()
|
||||
this.getSettings()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue