Browse Source

reservations: add PATCH endpoint and inline wifi/door editing in calendar.html

ZiB 4 months ago
parent
commit
940e3cde6d
3 changed files with 132 additions and 7 deletions
  1. 17 1
      room_cal/api/_routes/reservations.py
  2. 32 1
      room_cal/reservations/__init__.py
  3. 83 5
      room_cal/vue/calendar.html

+ 17 - 1
room_cal/api/_routes/reservations.py

@@ -1,5 +1,6 @@
+from dataclasses import asdict
 from datetime import date
-from fastapi import APIRouter
+from fastapi import APIRouter, Body, HTTPException
 from room_cal.reservations import Reservation
 from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
@@ -9,6 +10,7 @@ router = APIRouter()
 
 def _enrich(ev) -> dict:
   xd = safe_json_loads(ev.x_data) or {}
+  xd['uid'] = ev.uid
   xd['checkin_date'] = ev.start.date().isoformat()
   xd['checkout_date'] = ev.end.date().isoformat()
   xd['checkin_time'] = ev.start.strftime('%H:%M')
@@ -25,6 +27,20 @@ async def get_reservation(target_date: date):
   return None
 
 
+@router.patch('/reservation/{uid}')
+async def patch_reservation(uid: str, body: dict = Body(...)):
+  checkin_date = date.fromisoformat(body['checkin_date'])
+  result = await Reservation(DEFAULT_CALENDAR_NAME).patch(
+    uid, checkin_date,
+    wifi_ssid=body.get('wifi_ssid'),
+    wifi_key=body.get('wifi_key'),
+    door_code=body.get('door_code'),
+  )
+  if result is None:
+    raise HTTPException(status_code=404, detail='reservation not found')
+  return asdict(result)
+
+
 @router.get('/reservation/{start_date}/{end_date}')
 async def get_reservation_range(start_date: date, end_date: date):
   events = await Reservation(DEFAULT_CALENDAR_NAME).list(start_date, end_date)

+ 32 - 1
room_cal/reservations/__init__.py

@@ -1,6 +1,6 @@
 import json
 from dataclasses import dataclass, field
-from datetime import date, datetime
+from datetime import date, datetime, timedelta
 from zoneinfo import ZoneInfo
 from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import EventSource, safe_json_loads, filter_events, service_name, get_config
@@ -98,6 +98,37 @@ class Reservation(StoreBacked, EventSource):
     events = await self._fetch_date_range(start_date, end_date)
     return filter_events(events, _PREFIX, all_day=False)
 
+  async def patch(self, uid: str, checkin_date: date, *,
+                  wifi_ssid: str | None = None,
+                  wifi_key: str | None = None,
+                  door_code: str | None = None) -> 'ReservationData | None':
+    events = await self._fetch_date_range(checkin_date, checkin_date + timedelta(days=1))
+    event = next((e for e in filter_events(events, _PREFIX, all_day=False) if e.uid == uid), None)
+    if event is None:
+      return None
+    xd = safe_json_loads(event.x_data) or {}
+    if wifi_ssid is not None:
+      xd['wifi_ssid'] = wifi_ssid
+    if wifi_key is not None:
+      xd['wifi_key'] = wifi_key
+    if door_code is not None:
+      xd['door_code'] = door_code
+    wk = xd.get('wifi_key', '')
+    ws = xd.get('wifi_ssid')
+    dc = xd.get('door_code', '')
+    description = _build_description(xd, wk, ws, dc)
+    await self._backend.update(uid, description=description, x_data=json.dumps(xd))
+    return ReservationData(
+      guest_name=xd.get('guest_name'),
+      service=xd.get('service', ''),
+      reservation_id=xd.get('reservation_id', ''),
+      service_data=xd.get('service_data') or {},
+      chat_id=xd.get('chat_id'),
+      wifi_key=xd.get('wifi_key'),
+      wifi_ssid=xd.get('wifi_ssid'),
+      door_code=xd.get('door_code'),
+    )
+
   async def delete(self, event) -> None:
     await self._backend.delete(event.uid)
 

+ 83 - 5
room_cal/vue/calendar.html

@@ -1269,13 +1269,50 @@
               <span class="panel-label">Service</span>
               <span class="panel-value panel-id">{{ serviceName(selection.service) }}{{ selection.reservation_id ? ': ' + selection.reservation_id : '' }}</span>
             </div>
-            <div v-if="selection.door_code" class="panel-row">
+            <div class="panel-row" :class="{ 'is-saving': resDoorState==='saving', 'is-error': resDoorState==='error' }">
               <span class="panel-label">Door</span>
-              <span class="panel-value">{{ selection.door_code }}</span>
+              <span class="panel-value">
+                <input class="panel-input" type="text"
+                  :value="editingDoorCode ?? (selection.door_code || '')"
+                  @focus="editingDoorCode = selection.door_code || ''"
+                  @input="editingDoorCode = $event.target.value"
+                  @blur="commitResField('door_code')"
+                  @keydown.enter="$event.target.blur()"
+                  @keydown.escape="editingDoorCode = null; $event.target.blur()"
+                />
+                <span v-if="resDoorState==='saving'" class="row-spinner"></span>
+                <span v-else-if="resDoorState==='error'" class="row-err">Failed</span>
+              </span>
             </div>
-            <div v-if="selection.wifi_key" class="panel-row">
-              <span class="panel-label">WiFi</span>
-              <span class="panel-value">{{ selection.wifi_key }}</span>
+            <div class="panel-row" :class="{ 'is-saving': resKeyState==='saving', 'is-error': resKeyState==='error' }">
+              <span class="panel-label">WiFi key</span>
+              <span class="panel-value">
+                <input class="panel-input" type="text"
+                  :value="editingWifiKey ?? (selection.wifi_key || '')"
+                  @focus="editingWifiKey = selection.wifi_key || ''"
+                  @input="editingWifiKey = $event.target.value"
+                  @blur="commitResField('wifi_key')"
+                  @keydown.enter="$event.target.blur()"
+                  @keydown.escape="editingWifiKey = null; $event.target.blur()"
+                />
+                <span v-if="resKeyState==='saving'" class="row-spinner"></span>
+                <span v-else-if="resKeyState==='error'" class="row-err">Failed</span>
+              </span>
+            </div>
+            <div class="panel-row" :class="{ 'is-saving': resSsidState==='saving', 'is-error': resSsidState==='error' }">
+              <span class="panel-label">WiFi SSID</span>
+              <span class="panel-value">
+                <input class="panel-input" type="text"
+                  :value="editingWifiSsid ?? (selection.wifi_ssid || '')"
+                  @focus="editingWifiSsid = selection.wifi_ssid || ''"
+                  @input="editingWifiSsid = $event.target.value"
+                  @blur="commitResField('wifi_ssid')"
+                  @keydown.enter="$event.target.blur()"
+                  @keydown.escape="editingWifiSsid = null; $event.target.blur()"
+                />
+                <span v-if="resSsidState==='saving'" class="row-spinner"></span>
+                <span v-else-if="resSsidState==='error'" class="row-err">Failed</span>
+              </span>
             </div>
           </div>
         </div>
@@ -1416,6 +1453,12 @@ createApp({
     const selection    = ref(null);
     const editingBase         = ref(null);
     const editingDiscount     = ref(null);
+    const editingWifiSsid     = ref(null);
+    const editingWifiKey      = ref(null);
+    const editingDoorCode     = ref(null);
+    const resSsidState        = ref(null);
+    const resKeyState         = ref(null);
+    const resDoorState        = ref(null);
     const pendingDiscountType = ref(null);
     const pendingClosed       = ref(null);
     const discountInputRef    = ref(null);
@@ -1748,6 +1791,39 @@ createApp({
       }
     }
 
+    async function commitResField(field) {
+      const editingMap = { wifi_ssid: editingWifiSsid, wifi_key: editingWifiKey, door_code: editingDoorCode };
+      const stateMap   = { wifi_ssid: resSsidState,    wifi_key: resKeyState,    door_code: resDoorState };
+      const editRef  = editingMap[field];
+      const stateRef = stateMap[field];
+      const newVal   = editRef.value;
+      editRef.value  = null;
+      const original = selection.value?.[field] ?? '';
+      if (newVal === null || newVal === original) return;
+      stateRef.value = 'saving';
+      try {
+        const res = await fetch(`/reservation/${selection.value.uid}`, {
+          method: 'PATCH',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ checkin_date: selection.value.checkin_date, [field]: newVal }),
+        });
+        if (!res.ok) throw new Error(await res.text());
+        const updated = await res.json();
+        selection.value.wifi_key  = updated.wifi_key  ?? selection.value.wifi_key;
+        selection.value.wifi_ssid = updated.wifi_ssid ?? selection.value.wifi_ssid;
+        selection.value.door_code = updated.door_code ?? selection.value.door_code;
+        const cd = selection.value.checkin_date;
+        if (reservations.value[cd]) {
+          Object.assign(reservations.value[cd], {
+            wifi_key: updated.wifi_key, wifi_ssid: updated.wifi_ssid, door_code: updated.door_code,
+          });
+        }
+        stateRef.value = null;
+      } catch {
+        setError(stateRef);
+      }
+    }
+
     // ── Helpers ────────────────────────────────────────────
     function pad(n) { return String(n).padStart(2, '0'); }
 
@@ -1994,6 +2070,7 @@ createApp({
       selection.value = {
         type:           'res',
         key:            span.resKey,
+        uid:            span.uid,
         guest_name:     span.guest_name,
         service:        span.service,
         reservation_id: span.reservation_id,
@@ -2025,6 +2102,7 @@ createApp({
           label:          (res.guest_name || 'Guest').split(' ')[0],
           cls:            'res',
           resKey:         key,
+          uid:            res.uid ?? null,
           guest_name:     res.guest_name,
           service:        res.service,
           reservation_id: res.reservation_id,