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:
Padreug 2026-07-20 00:44:10 +02:00
commit e6973fa192
2 changed files with 487 additions and 16 deletions

View file

@ -1,18 +1,257 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %}
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('chatelet/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12">
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">Chatelet — Room Rentals</h5>
<p class="text-caption">
Nostr-native room rentals. Operator admin UI goes here (rooms,
calendar, bookings). Guest booking UI lives in the webapp.
<div class="row items-center q-mb-md">
<div class="col">
<h4 class="q-my-none">Chatelet — Room Rentals</h4>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Operator admin. The guest booking UI lives in the webapp.
</p>
</q-card-section>
</div>
</div>
<q-card>
<q-tabs
v-model="tab" align="left"
active-color="primary" indicator-color="primary" class="text-grey">
<q-tab name="rooms" icon="hotel" label="Rooms"></q-tab>
<q-tab name="bookings" icon="event_available" label="Bookings"></q-tab>
<q-tab name="blocks" icon="event_busy" label="Calendar blocks"></q-tab>
<q-tab name="settings" icon="settings" label="Settings"></q-tab>
</q-tabs>
<q-separator></q-separator>
<q-tab-panels v-model="tab" animated>
<!-- ROOMS -->
<q-tab-panel name="rooms">
<div class="row items-center q-mb-md">
<div class="col"><div class="text-h6">Rooms</div></div>
<div class="col-auto">
<q-btn color="primary" icon="add" label="Add room" @click="newRoom"></q-btn>
</div>
</div>
<q-table
:rows="rooms" :columns="roomsColumns" row-key="id"
:loading="roomsLoading" flat :pagination="{rowsPerPage: 0}"
no-data-label="No rooms yet — add one to get started">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
${ props.value }
</q-chip>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn
v-if="props.row.status !== 'active'"
flat dense round icon="cloud_upload" color="green"
@click="setPublished(props.row, true)">
<q-tooltip>Publish to relays</q-tooltip>
</q-btn>
<q-btn
v-else flat dense round icon="cloud_off" color="grey"
@click="setPublished(props.row, false)">
<q-tooltip>Unpublish</q-tooltip>
</q-btn>
<q-btn flat dense round icon="edit" color="primary"
@click="editRoom(props.row)">
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn flat dense round icon="delete" color="negative"
@click="deleteRoom(props.row)">
<q-tooltip>Delete</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- BOOKINGS -->
<q-tab-panel name="bookings">
<div class="row items-center q-mb-md q-gutter-sm">
<q-select
style="min-width: 260px" outlined dense
v-model="bookingsRoomId" :options="roomOptions"
emit-value map-options label="Room"
@update:model-value="loadBookings"></q-select>
<q-btn flat dense icon="refresh" @click="loadBookings"
:disable="!bookingsRoomId">
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<q-table
:rows="bookings" :columns="bookingsColumns" row-key="id" flat
:pagination="{rowsPerPage: 0}"
no-data-label="Pick a room to see its bookings">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
${ props.value }
</q-chip>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- CALENDAR BLOCKS -->
<q-tab-panel name="blocks">
<div class="row items-center q-mb-md q-gutter-sm">
<q-select
style="min-width: 260px" outlined dense
v-model="blocksRoomId" :options="roomOptions"
emit-value map-options label="Room"
@update:model-value="loadBlocks"></q-select>
<q-btn flat dense icon="refresh" @click="loadBlocks"
:disable="!blocksRoomId">
<q-tooltip>Refresh</q-tooltip>
</q-btn>
</div>
<div v-if="blocksRoomId" class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input outlined dense type="date" v-model="blockForm.start_date"
label="From"></q-input>
</div>
<div class="col-auto">
<q-input outlined dense type="date" v-model="blockForm.end_date"
label="To (exclusive)"></q-input>
</div>
<div class="col">
<q-input outlined dense v-model="blockForm.reason"
label="Reason (optional)"></q-input>
</div>
<div class="col-auto">
<q-btn color="primary" icon="add" label="Block" @click="addBlock"
:disable="!blockForm.start_date || !blockForm.end_date"></q-btn>
</div>
</div>
<q-table
:rows="blocks" :columns="blocksColumns" row-key="id" flat
:pagination="{rowsPerPage: 0}"
no-data-label="No manual blocks for this room">
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense round icon="delete" color="negative"
@click="deleteBlock(props.row)"></q-btn>
</q-td>
</template>
</q-table>
</q-tab-panel>
<!-- SETTINGS -->
<q-tab-panel name="settings">
<div class="text-h6 q-mb-md">Operator settings</div>
<div class="row q-col-gutter-md" style="max-width: 760px">
<div class="col-12">
<q-input outlined dense v-model="settings.operator_id"
label="Operator account id"
hint="LNbits account whose Nostr signer publishes listings + check-in DMs (bunker-backed for the encrypted flows)"></q-input>
</div>
<div class="col-12">
<q-select outlined dense v-model="settings.relays" label="Relays"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"
hint="Relays where listings + availability are published"></q-select>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense type="number" label="Hold minutes"
v-model.number="settings.default_hold_minutes"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense type="number" label="Deposit %"
v-model.number="settings.deposit_percent"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-in time"
v-model="settings.checkin_time"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-out time"
v-model="settings.checkout_time"></q-input>
</div>
<div class="col-12">
<q-input outlined dense type="textarea" autogrow
label="Cancellation policy"
v-model="settings.cancellation_policy"></q-input>
</div>
<div class="col-12">
<q-toggle v-model="settings.publish_availability"
label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle>
</div>
<div class="col-12">
<q-btn color="primary" label="Save settings" @click="saveSettings"
:loading="settingsLoading"></q-btn>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('chatelet/static', 'js/index.js') }}"></script>
<!-- ROOM CREATE / EDIT DIALOG -->
<q-dialog v-model="roomDialog.show">
<q-card style="min-width: 480px; max-width: 640px">
<q-card-section>
<div class="text-h6">${ roomDialog.data.id ? 'Edit room' : 'New room' }</div>
</q-card-section>
<q-card-section class="q-gutter-sm scroll" style="max-height: 70vh">
<q-input outlined dense v-model="roomDialog.data.title" label="Title *"></q-input>
<q-input outlined dense type="textarea" autogrow
v-model="roomDialog.data.description" label="Description (markdown)"></q-input>
<div class="row q-col-gutter-sm">
<div class="col-4">
<q-input outlined dense type="number" label="Price *"
v-model.number="roomDialog.data.price_amount"></q-input>
</div>
<div class="col-4">
<q-select outlined dense label="Currency" :options="currencyOptions"
v-model="roomDialog.data.price_currency"></q-select>
</div>
<div class="col-4">
<q-select outlined dense label="Per" :options="frequencyOptions"
v-model="roomDialog.data.price_frequency"></q-select>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-input outlined dense type="number" label="Max guests"
v-model.number="roomDialog.data.max_guests"></q-input>
</div>
<div class="col-6">
<q-input outlined dense type="number" label="Min nights"
v-model.number="roomDialog.data.min_nights"></q-input>
</div>
</div>
<q-input outlined dense v-model="roomDialog.data.location" label="Location"></q-input>
<q-input outlined dense v-model="roomDialog.data.geohash"
label="Geohash (optional)"></q-input>
<q-select outlined dense v-model="roomDialog.data.amenities" label="Amenities"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"></q-select>
<q-select outlined dense v-model="roomDialog.data.images" label="Image URLs"
use-chips multiple use-input hide-dropdown-icon
new-value-mode="add-unique"></q-select>
<q-input outlined dense type="textarea" autogrow
v-model="roomDialog.data.checkin_instructions"
label="Check-in instructions (private — sent only in the encrypted DM)"></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="saveRoom"
:disable="!roomDialog.data.title || roomDialog.data.price_amount == null"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}