mileztone17
This commit is contained in:
parent
4dd33c90e1
commit
aa3436fd1d
@ -41,7 +41,7 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
|
||||
| 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. |
|
||||
| 15 | Knowledge, AI, and FAQ activation | B | In progress | Owners can run a bounded no-send batch evaluation against current FAQ rules and approved knowledge, with false-positive/negative results and documented zero-error activation thresholds and stop conditions. Curate hotel-specific cases, evaluate AI suggestions separately, train staff, name monitoring/rollback owners, and retain staged-activation evidence. |
|
||||
| 16 | Identity, preferences, and privacy | B/C | In progress | Login throttling now uses the client address only after one-hop processing from the explicitly trusted reverse proxy. Finish privacy/retention decisions, preference coverage, identity acceptance and audit review. |
|
||||
| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Inbox, activity, mailbox-health and FAQ-history timestamps use the saved hotel timezone; finish the remaining secondary screens and agreed desktop-parity acceptance. |
|
||||
| 17 | Inbox usability and desktop parity | B | Implemented / acceptance required | The inbox uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during route/history navigation, reload, conversation selection, filtering and search. Operational timestamps use the saved hotel timezone, and a release-bound desktop-parity acceptance record is implemented. The implementation and preview HTTP suite pass; run the supervised exercise against the approved release and retain independent approval. |
|
||||
| 18 | Pilot, capacity, and release approval | B/C | In progress | A bounded read-only sandbox capacity probe, machine-validated pilot decision and Gate B/C incident-exercise record are implemented. Agree targets, run the probe with host monitoring, complete the supervised pilot and incident exercises, resolve or explicitly contain findings, and retain separate hotel-owner and technical go/no-go decisions. |
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
@ -10,7 +10,7 @@ The reviewed candidate is now promoted into the local `main` history. It is not
|
||||
- Approval-controlled OHIP PMS and NMI payment workflows.
|
||||
- FAQ automation controls, team invitations, password recovery, and stronger Google connection recovery.
|
||||
- No-send FAQ batch evaluation with false-positive and false-negative reporting before activation.
|
||||
- Stable tenant-scoped inbox pagination beyond the former 500-message view, stronger unsaved-draft navigation guards, and hotel-timezone inbox timestamps.
|
||||
- Stable tenant-scoped inbox pagination beyond the former 500-message view, unsaved-draft guards including browser history navigation, consistent hotel-timezone timestamps across operational screens, and a release-bound desktop-parity acceptance record.
|
||||
- A bounded read-only sandbox capacity probe plus machine-validated, release-bound supervised-pilot and incident-exercise records.
|
||||
- Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling.
|
||||
|
||||
@ -19,7 +19,7 @@ These capabilities still require their separately documented provider, host and
|
||||
### Known limitations and launch conditions
|
||||
|
||||
- Gate A still requires a successful default-branch CI run, durable off-host release archive, target-Debian deployment, persistent storage/key validation, monitoring, and a successful restore/rollback exercise.
|
||||
- Gate B still requires real Google acceptance and supervised staff testing. Before pilot use, safely paginate beyond the 500-conversation limit, protect drafts across every navigation path, make login throttling proxy-aware, and render dates in the saved hotel timezone—or record and approve explicit operational containment.
|
||||
- Gate B still requires real Google acceptance and supervised staff testing, including desktop-parity acceptance of pagination, draft protection, proxy-aware login throttling and saved-hotel-timezone rendering.
|
||||
- Gate C still requires the Rezlynx/Guestline adapter and independently accepted PMS/payment workflows, plus privacy, identity, capacity, and release approvals.
|
||||
- FAQ live mode and all external write actions must remain disabled until their corresponding acceptance gate has passed.
|
||||
|
||||
|
||||
31
deploy/desktop-acceptance.example.json
Normal file
31
deploy/desktop-acceptance.example.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-desktop-parity",
|
||||
"dataClassification": "synthetic-only",
|
||||
"desktopBaselineCommit": "18b983bf402ecdded6430fd40bc4d3320587595a",
|
||||
"releaseCommit": "0000000000000000000000000000000000000000",
|
||||
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"environment": "https://sandbox-guestops.example.invalid",
|
||||
"browser": {
|
||||
"name": "Microsoft Edge",
|
||||
"version": "REPLACE_WITH_FULL_VERSION",
|
||||
"operatingSystem": "Windows 11"
|
||||
},
|
||||
"viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1},
|
||||
"hotelTimeZone": "Europe/London",
|
||||
"operator": "Acceptance operator",
|
||||
"reviewedBy": "Independent reviewer",
|
||||
"startedAt": "2026-09-29T09:00:00Z",
|
||||
"endedAt": "2026-09-29T10:00:00Z",
|
||||
"reviewedAt": "2026-09-29T11:00:00Z",
|
||||
"scenarios": [
|
||||
{"id": "desktop-layout", "status": "not-run", "evidence": []},
|
||||
{"id": "draft-conversation-guard", "status": "not-run", "evidence": []},
|
||||
{"id": "draft-filter-search-guard", "status": "not-run", "evidence": []},
|
||||
{"id": "draft-route-history-reload-guard", "status": "not-run", "evidence": []},
|
||||
{"id": "inbox-core-workflow", "status": "not-run", "evidence": []},
|
||||
{"id": "pagination-beyond-500", "status": "not-run", "evidence": []},
|
||||
{"id": "role-and-control-parity", "status": "not-run", "evidence": []},
|
||||
{"id": "timezone-and-dst", "status": "not-run", "evidence": []}
|
||||
]
|
||||
}
|
||||
121
deploy/desktop_acceptance.py
Normal file
121
deploy/desktop_acceptance.py
Normal file
@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a restricted GuestOps desktop-parity acceptance record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DESKTOP_BASELINE = "18b983bf402ecdded6430fd40bc4d3320587595a"
|
||||
SCENARIOS = {
|
||||
"inbox-core-workflow",
|
||||
"pagination-beyond-500",
|
||||
"draft-conversation-guard",
|
||||
"draft-filter-search-guard",
|
||||
"draft-route-history-reload-guard",
|
||||
"timezone-and-dst",
|
||||
"role-and-control-parity",
|
||||
"desktop-layout",
|
||||
}
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def timestamp(value: object, field: str) -> dt.datetime:
|
||||
require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.")
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{field} is not a valid timestamp.") from error
|
||||
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
|
||||
return parsed
|
||||
|
||||
|
||||
def safe_name(value: object, field: str) -> str:
|
||||
name = str(value or "").strip()
|
||||
require(2 <= len(name) <= 120 and "@" not in name, f"{field} requires a name without an email address.")
|
||||
return name
|
||||
|
||||
|
||||
def validate(record: object) -> None:
|
||||
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
|
||||
require(record.get("schemaVersion") == 1, "Unsupported acceptance record schema.")
|
||||
require(record.get("system") == "guestops-desktop-parity",
|
||||
"Acceptance record system must be guestops-desktop-parity.")
|
||||
require(record.get("dataClassification") == "synthetic-only",
|
||||
"Desktop acceptance must use synthetic data only.")
|
||||
require(record.get("desktopBaselineCommit") == DESKTOP_BASELINE,
|
||||
"desktopBaselineCommit must identify the reviewed desktop baseline.")
|
||||
require(re.fullmatch(r"[0-9a-f]{40}", str(record.get("releaseCommit", ""))) is not None,
|
||||
"releaseCommit must be a full lowercase Git SHA.")
|
||||
require(re.fullmatch(r"[0-9a-f]{64}", str(record.get("releaseRecordSha256", ""))) is not None,
|
||||
"releaseRecordSha256 must be a SHA-256 digest.")
|
||||
|
||||
origin = urlparse(str(record.get("environment", "")))
|
||||
require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/")
|
||||
and not origin.query and not origin.fragment and origin.username is None and origin.password is None,
|
||||
"environment must be an HTTPS origin without credentials, path, query or fragment.")
|
||||
|
||||
browser = record.get("browser")
|
||||
require(isinstance(browser, dict) and set(browser) == {"name", "version", "operatingSystem"},
|
||||
"browser must contain exactly name, version and operatingSystem.")
|
||||
for field in ("name", "version", "operatingSystem"):
|
||||
require(2 <= len(str(browser.get(field, "")).strip()) <= 120,
|
||||
f"browser.{field} is required.")
|
||||
viewport = record.get("viewport")
|
||||
require(isinstance(viewport, dict) and set(viewport) == {"width", "height", "deviceScaleFactor"},
|
||||
"viewport must contain exactly width, height and deviceScaleFactor.")
|
||||
require(isinstance(viewport["width"], int) and not isinstance(viewport["width"], bool)
|
||||
and 1280 <= viewport["width"] <= 7680, "Desktop viewport width must be between 1280 and 7680 pixels.")
|
||||
require(isinstance(viewport["height"], int) and not isinstance(viewport["height"], bool)
|
||||
and 720 <= viewport["height"] <= 4320, "Desktop viewport height must be between 720 and 4320 pixels.")
|
||||
require(isinstance(viewport["deviceScaleFactor"], (int, float)) and not isinstance(viewport["deviceScaleFactor"], bool)
|
||||
and 0.5 <= viewport["deviceScaleFactor"] <= 4, "deviceScaleFactor must be between 0.5 and 4.")
|
||||
time_zone = str(record.get("hotelTimeZone", ""))
|
||||
require(time_zone == "UTC" or re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+\-]+)+", time_zone) is not None,
|
||||
"hotelTimeZone must be UTC or an IANA timezone name.")
|
||||
|
||||
operator = safe_name(record.get("operator"), "operator")
|
||||
reviewer = safe_name(record.get("reviewedBy"), "reviewedBy")
|
||||
require(operator.casefold() != reviewer.casefold(), "operator and reviewedBy must be different people.")
|
||||
started = timestamp(record.get("startedAt"), "startedAt")
|
||||
ended = timestamp(record.get("endedAt"), "endedAt")
|
||||
reviewed = timestamp(record.get("reviewedAt"), "reviewedAt")
|
||||
require(started <= ended <= reviewed, "Acceptance timestamps are out of order.")
|
||||
|
||||
scenarios = record.get("scenarios")
|
||||
require(isinstance(scenarios, list), "scenarios must be a list.")
|
||||
ids = [item.get("id") for item in scenarios if isinstance(item, dict)]
|
||||
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == SCENARIOS,
|
||||
"Acceptance record requires the exact desktop scenario set.")
|
||||
for item in scenarios:
|
||||
scenario_id = item["id"]
|
||||
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
|
||||
evidence = item.get("evidence")
|
||||
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all(
|
||||
isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value for value in evidence
|
||||
), f"Scenario {scenario_id} requires safe opaque evidence references without email addresses.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("record", type=Path)
|
||||
args = parser.parse_args()
|
||||
validate(json.loads(args.record.read_text(encoding="utf-8")))
|
||||
print(f"Desktop-parity acceptance record is structurally complete: {len(SCENARIOS)} scenarios passed. This validates the record, not its restricted evidence.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Desktop-parity acceptance record rejected: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
28
docs/desktop-acceptance.md
Normal file
28
docs/desktop-acceptance.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Desktop-parity acceptance
|
||||
|
||||
Run this supervised exercise against the exact HTTPS sandbox release using synthetic conversations and accounts. Compare the web workflow with the reviewed desktop baseline `18b983bf402ecdded6430fd40bc4d3320587595a`; this is workflow and safety parity, not a claim that the interfaces are visually identical.
|
||||
|
||||
Use the browser and workstation configuration intended for the pilot. Record its full browser version, operating system, viewport, scale factor and the hotel's saved IANA timezone. Keep screenshots and recordings in the restricted acceptance store because UI evidence may contain message text or account details. The operator and independent reviewer must be different people.
|
||||
|
||||
## Required scenarios
|
||||
|
||||
| Record ID | Exercise | Passing result |
|
||||
| --- | --- | --- |
|
||||
| `inbox-core-workflow` | Open a synthetic conversation, create and save a draft, use an approved answer, change status and reload. | The same conversation and saved state remain available, and no action sends mail. |
|
||||
| `pagination-beyond-500` | Seed more than 500 tenant-scoped conversations and load successive 50-item pages while another conversation is inserted. | Every original record can be reached once without cross-hotel records, duplicates or skipped records. |
|
||||
| `draft-conversation-guard` | Edit without saving, select another conversation, reject the discard prompt, then accept it. | Rejection retains the draft and selection; acceptance changes conversation and discards only the unsaved edit. |
|
||||
| `draft-filter-search-guard` | Repeat the reject/accept checks while changing status filters and search text. | Rejection preserves the edit and prior view; acceptance applies the requested view change. |
|
||||
| `draft-route-history-reload-guard` | With an unsaved edit, try sidebar navigation, browser Back/Forward and reload or tab close. | In-app navigation and browser history require confirmation; reload or close raises the browser's native unsaved-change warning. |
|
||||
| `timezone-and-dst` | Choose a timezone different from the workstation and inspect inbox, activity, health, mailbox, automation, PMS, payment and team-link times, including values around a daylight-saving transition. | Every operational timestamp follows the saved hotel timezone and represents the same instant consistently. |
|
||||
| `role-and-control-parity` | Exercise owner and staff accounts with provider writes and automation disabled. | Staff cannot access owner functions, and neither role can bypass feature, review or provider controls. |
|
||||
| `desktop-layout` | Complete the workflow at the recorded desktop viewport and scale, including keyboard navigation and browser zoom checks agreed for the pilot. | Primary controls remain visible and usable without clipped dialogs, overlapping content or an inaccessible action. |
|
||||
|
||||
## Record and validation
|
||||
|
||||
Copy `deploy/desktop-acceptance.example.json` into the restricted acceptance store. Replace its release identifiers, environment, workstation details, timestamps and people. Mark each scenario `pass` only after the independent reviewer has checked its evidence. The example is intentionally invalid while scenarios are `not-run`.
|
||||
|
||||
```sh
|
||||
python3 deploy/desktop_acceptance.py /secure/acceptance/desktop-acceptance.json
|
||||
```
|
||||
|
||||
The validator checks structure, release and baseline binding, desktop dimensions, independent review, complete passing scenarios and opaque evidence references. It cannot inspect screenshots or prove browser behavior. Retain the validated record and its checksum, then reference it from `inbox-usability` in the pilot approval record.
|
||||
@ -48,3 +48,5 @@ python3 deploy/pilot_approval.py /secure/acceptance/pilot-approval.json
|
||||
The validator requires the exact Gate B evidence set, or that set plus independently accepted PMS and payment-provider evidence for Gate C. It also verifies that observed concurrency meets the pre-agreed target and that p95 latency and error rate remain within their pre-agreed bounds. Structural validation does not inspect evidence or authorize rollout by itself.
|
||||
|
||||
Complete the [incident and rollback exercise](incident-exercise.md) before marking `incident-support` as passed. Its record must use the same release identifiers and target gate as this decision. Reference the retained exercise record and validator output; do not substitute a local automated-test result for the supervised exercise.
|
||||
|
||||
Complete the [desktop-parity acceptance exercise](desktop-acceptance.md) before marking `inbox-usability` as passed. Bind it to the same release identifiers and retain its independently reviewed record outside the repository.
|
||||
|
||||
65
tests/test_desktop_acceptance.py
Normal file
65
tests/test_desktop_acceptance.py
Normal file
@ -0,0 +1,65 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("desktop_acceptance", Path(__file__).resolve().parents[1] / "deploy" / "desktop_acceptance.py")
|
||||
acceptance = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(acceptance)
|
||||
|
||||
|
||||
def valid_record():
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-desktop-parity",
|
||||
"dataClassification": "synthetic-only",
|
||||
"desktopBaselineCommit": acceptance.DESKTOP_BASELINE,
|
||||
"releaseCommit": "a" * 40,
|
||||
"releaseRecordSha256": "b" * 64,
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"browser": {"name": "Microsoft Edge", "version": "140.0.0.0", "operatingSystem": "Windows 11"},
|
||||
"viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1},
|
||||
"hotelTimeZone": "Europe/London",
|
||||
"operator": "Acceptance operator",
|
||||
"reviewedBy": "Independent reviewer",
|
||||
"startedAt": "2026-09-29T09:00:00Z",
|
||||
"endedAt": "2026-09-29T10:00:00Z",
|
||||
"reviewedAt": "2026-09-29T11:00:00Z",
|
||||
"scenarios": [
|
||||
{"id": scenario, "status": "pass", "evidence": [f"restricted-ticket-{index}"]}
|
||||
for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DesktopAcceptanceTests(unittest.TestCase):
|
||||
def test_complete_record_passes(self):
|
||||
acceptance.validate(valid_record())
|
||||
|
||||
def test_exact_scenarios_and_desktop_baseline_are_required(self):
|
||||
record = valid_record();record["scenarios"].pop()
|
||||
with self.assertRaisesRegex(ValueError, "exact desktop scenario"):
|
||||
acceptance.validate(record)
|
||||
record = valid_record();record["desktopBaselineCommit"] = "c" * 40
|
||||
with self.assertRaisesRegex(ValueError, "reviewed desktop baseline"):
|
||||
acceptance.validate(record)
|
||||
|
||||
def test_desktop_viewport_and_timezone_are_required(self):
|
||||
record = valid_record();record["viewport"]["width"] = 1024
|
||||
with self.assertRaisesRegex(ValueError, "Desktop viewport width"):
|
||||
acceptance.validate(record)
|
||||
record = valid_record();record["hotelTimeZone"] = "local browser time"
|
||||
with self.assertRaisesRegex(ValueError, "IANA timezone"):
|
||||
acceptance.validate(record)
|
||||
|
||||
def test_independent_review_and_safe_evidence_are_required(self):
|
||||
record = valid_record();record["reviewedBy"] = record["operator"]
|
||||
with self.assertRaisesRegex(ValueError, "different people"):
|
||||
acceptance.validate(record)
|
||||
record = valid_record();record["scenarios"][0]["evidence"] = ["guest@example.invalid"]
|
||||
with self.assertRaisesRegex(ValueError, "safe opaque"):
|
||||
acceptance.validate(record)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type Hotel } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Payment={id:string;reference:string;email:string;description:string;amount:number;currency:string;state:string;detail:string;invoiceId:string;version:number;expiresAt:string;updatedAt:string;checkedAt:string|null};
|
||||
type Connection={configured:boolean;createsConfigured:boolean;sandbox:boolean;preview:boolean};
|
||||
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
@ -19,7 +20,7 @@ export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
<section className="settings-card"><h2>Payment connection</h2><p>{connection?.preview?'Sample workspace: no invoices, emails or payments can be created.':connection?.configured?`NMI ${connection.sandbox?'sandbox':'production'} configuration is available.`:'Your administrator needs to configure this hotel’s NMI merchant account.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.paymentsEnabled||false} disabled={busy||!owner||(!connection?.createsConfigured&&!hotel.paymentsEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable reviewed NMI invoice creation after sandbox acceptance? Creating an invoice may email the customer.'))return;onHotel(await api<Hotel>('/payments/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved payment invoices</label><p className="small muted">Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!owner||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
|
||||
<section className="settings-card"><h2>Payment history</h2>{items.length===0&&<p>No payment requests yet.</p>}<div className="pms-history">{items.map(p=><button key={p.id} className={'pms-history-item '+(selected===p.id?'selected':'')} onClick={()=>{setSelected(p.id);setApproved(false);}}><strong>{p.reference} · {p.currency} {p.amount.toFixed(2)}</strong><span>{p.state==='NeedsReview'?'Needs verification':p.state==='Paid'?'Paid · reported by NMI':p.state} · {p.email}</span></button>)}</div></section></div>
|
||||
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?new Date(current.checkedAt).toLocaleString():'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
|
||||
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?hotelTime(current.checkedAt,hotel.timezone):'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
|
||||
{current.state==='Review'?<><div className="staff-note">Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.</div><label className="checkbox-label"><input type="checkbox" checked={approved} onChange={e=>setApproved(e.target.checked)}/>I checked the recipient, amount and currency, and approve NMI emailing this payment request.</label><div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||connection?.preview||!hotel.paymentsEnabled||!connection?.createsConfigured||!approved||Date.now()>new Date(current.expiresAt).getTime()} onClick={()=>action('create')}>Approve and create invoice</button></div><p className="small muted">Approval expires after fifteen minutes. A payment reference cannot be reused.</p></>:!['Cancelled','NotCreated'].includes(current.state)&&<><button className="button secondary" disabled={busy||!owner||connection?.preview||(current.state==='Creating'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('check')}>Verify with NMI</button><p className="small muted">This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.</p></>}
|
||||
<p className="small muted">A paid invoice is not confirmation of bank settlement or a hotel booking. Refunds, invoice closure and disputes are handled in the merchant portal.</p>
|
||||
</section>}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type Hotel } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Snapshot={id:string;reservationId:string;confirmation:string;guestName:string;arrival:string;departure:string;status:string;roomType:string;total:string;fetchedAt:string};
|
||||
type Change={id:string;reservationId:string;kind:string;arrival:string;departure:string;note:string;state:string;detail:string;version:number;updatedAt:string;expiresAt:string;before:Snapshot;after:Snapshot|null};
|
||||
type Connection={configured:boolean;writesConfigured:boolean;hotelCode:string;preview:boolean};
|
||||
@ -21,7 +22,7 @@ export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
<section className="settings-card"><h2>OHIP connection</h2><p>{connection?.configured?`Connected configuration for property ${connection.hotelCode}. Lookup will verify access.`:connection?.preview?'Sample workspace: real PMS lookup and updates are disabled.':'Your administrator needs to configure this hotel’s OHIP connection.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.pmsUpdatesEnabled||false} disabled={busy||!owner||(!connection?.writesConfigured&&!hotel.pmsUpdatesEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable staff-approved PMS updates for this hotel? Only enable this after the configured OHIP sandbox has passed acceptance checks.'))return;onHotel(await api<Hotel>('/pms/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved PMS updates</label><p className="small muted">Lookup is available separately. Every change needs review; automatic PMS updates are off.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Find a reservation</h2><form onSubmit={lookup}><label>Exact confirmation number<input value={confirmation} maxLength={80} pattern="[a-zA-Z0-9-]+" required onChange={e=>setConfirmation(e.target.value)} placeholder="For example, 12345678"/></label><button className="button primary" disabled={busy||!connection?.configured}>Look up reservation</button></form>
|
||||
{snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!owner}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>}
|
||||
</section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {new Date(c.updatedAt).toLocaleString()}</span></button>)}</div></section></div>
|
||||
</section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {hotelTime(c.updatedAt,hotel.timezone)}</span></button>)}</div></section></div>
|
||||
{current&&<section className="settings-card pms-review" aria-label="PMS change review"><h2>{current.state==='Review'?'Review this PMS change':'PMS change status'}</h2><Reservation value={current.before}/><div className="staff-note">{current.kind==='StayDates'?`Requested stay: ${current.arrival} to ${current.departure}`:`Add internal note: ${current.note}`}</div><p role="status"><strong>{current.state==='NeedsReview'?'Needs verification':current.state}</strong> — {current.detail}</p>{current.after&&<><h3>Latest observed PMS state</h3><Reservation value={current.after}/></>}{current.state==='Review'&&<>{current.kind==='StayDates'&&<label className="checkbox-label"><input type="checkbox" checked={checked} onChange={e=>setChecked(e.target.checked)}/>I checked availability, rate consequences and guest agreement in the PMS. GuestOps does not quote or guarantee a new price here.</label>}<div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||!hotel.pmsUpdatesEnabled||!connection?.writesConfigured||(current.kind==='StayDates'&&!checked)||new Date(current.expiresAt)<new Date()} onClick={()=>action('apply')}>Approve and apply to PMS</button></div></>}{(current.state==='NeedsReview'||current.state==='Applying')&&<><button className="button secondary" disabled={busy||!owner||(current.state==='Applying'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('verify')}>Verify current PMS state</button><p className="small muted">An interrupted update can be checked after five minutes. Verification only reads the PMS; it never repeats the update.</p></>}<p className="small muted">Operation reference: {current.id}</p></section>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Member={id:string;name:string;email:string;role:string;active:boolean;pending:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
|
||||
type Link={userId:string;link:string;expiresAt:string};
|
||||
export function TeamPage({owner}:{owner:boolean}) {
|
||||
export function TeamPage({owner,timeZone}:{owner:boolean;timeZone:string}) {
|
||||
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
|
||||
async function refresh(){setMembers(await api<Member[]>('/team'));}
|
||||
useEffect(()=>{if(owner)refresh().catch(e=>setError(e.message));},[owner]);
|
||||
async function act(path:string,body:unknown){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,'POST',body);if(result?.link)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
|
||||
if(!owner)return <div className="page"><h1>Team access</h1><p>Your hotel owner manages staff accounts.</p></div>;
|
||||
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">A place for everyone</span><h1>Your team</h1><p>Give each colleague their own access to the hotel workspace.</p></div>{error&&<div className="alert" role="alert">{error}</div>}
|
||||
{link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {new Date(link.expiresAt).toLocaleString()}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>}
|
||||
{link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {hotelTime(link.expiresAt,timeZone)}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>}
|
||||
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Staff can work on guest conversations. Owners manage hotel settings, integrations and approvals.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label></div><div className="form-actions"><button className="button primary">Create invitation link</button></div></fieldset></form></section>
|
||||
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {new Date(m.linkExpiresAt!).toLocaleString()}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section>
|
||||
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section>
|
||||
</div>;
|
||||
}
|
||||
type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react';
|
||||
import { api, session, type Session, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
|
||||
@ -19,6 +19,7 @@ const date = (value: string,timeZone: string) => new Date(value).toLocaleString(
|
||||
function App() {
|
||||
const [auth,setAuth] = useState<Session|null>(null), [error,setError] = useState(''), [notice,setNotice] = useState('');
|
||||
const [hotel,setHotel] = useState<Hotel|null>(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false);
|
||||
const pageRef=useRef(page);
|
||||
const [conversations,setConversations] = useState<Conversation[]>([]), [knowledge,setKnowledge] = useState<Knowledge[]>([]), [activity,setActivity] = useState<Activity[]>([]), [mailboxes,setMailboxes] = useState<Mailboxes>({ configured:false,items:[] });
|
||||
const [conversationCursor,setConversationCursor]=useState<string|null>(null);
|
||||
const [loaded,setLoaded] = useState(false);
|
||||
@ -27,7 +28,8 @@ function App() {
|
||||
setHotel(h);setConversations(c.items);setConversationCursor(c.nextCursor);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
|
||||
}
|
||||
async function moreConversations(){if(!conversationCursor)return;const page=await api<ConversationPage>('/conversations/page?cursor='+encodeURIComponent(conversationCursor));setConversations(old=>[...old,...page.items.filter(item=>!old.some(existing=>existing.id===item.id))]);setConversationCursor(page.nextCursor);}
|
||||
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>setPage(location.pathname);window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
|
||||
useEffect(()=>{pageRef.current=page;},[page]);
|
||||
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>{if(!window.dispatchEvent(new Event('workspace-navigate',{cancelable:true}))){history.pushState({},'',pageRef.current);return;}setPage(location.pathname==='/'?'/inbox':location.pathname);};window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
|
||||
useEffect(()=>{if(auth?.user) refresh().catch(e=>setError(e.message));},[auth?.user?.id]);
|
||||
useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]);
|
||||
async function run(action:()=>Promise<void>) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} }
|
||||
@ -51,7 +53,7 @@ function App() {
|
||||
<main className="main">
|
||||
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
||||
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,hotel!.timezone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'} timeZone={hotel!.timezone}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,hotel!.timezone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user