GuestOps/web/src/PmsPage.tsx
2026-09-30 10:24:27 +01:00

30 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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};
type Props={hotel:Hotel;owner:boolean;canPropose:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function PmsPage({hotel,owner,canPropose,busy,run,onHotel}:Props){
const [connection,setConnection]=useState<Connection|null>(null),[changes,setChanges]=useState<Change[]>([]),[confirmation,setConfirmation]=useState(''),[snapshot,setSnapshot]=useState<Snapshot|null>(null);
const [kind,setKind]=useState('AddNote'),[arrival,setArrival]=useState(''),[departure,setDeparture]=useState(''),[note,setNote]=useState(''),[selected,setSelected]=useState<string|null>(null),[checked,setChecked]=useState(false);
async function refresh(){const [status,history]=await Promise.all([api<Connection>('/pms/status'),api<Change[]>('/pms/changes')]);setConnection(status);setChanges(history);}
useEffect(()=>{run(refresh);},[hotel.id]);
const current=changes.find(c=>c.id===selected);
function update(change:Change){setChanges(old=>[change,...old.filter(c=>c.id!==change.id)]);setSelected(change.id);setChecked(false);}
async function lookup(e:React.FormEvent){e.preventDefault();await run(async()=>{const found=await api<Snapshot>('/pms/lookup','POST',{confirmation:confirmation.trim()});setSnapshot(found);setArrival(found.arrival);setDeparture(found.departure);setSelected(null);setNote('');setChecked(false);});}
async function propose(e:React.FormEvent){e.preventDefault();if(!snapshot)return;await run(async()=>update(await api<Change>('/pms/changes','POST',{snapshotId:snapshot.id,kind,arrival,departure,note})));}
async function action(name:string){if(!current)return;await run(async()=>{
if(name==='apply'&&!window.confirm(`Apply this change to OHIP reservation ${current.before.confirmation} for ${current.before.guestName||'the displayed guest'}?\n\n${current.kind==='StayDates'?`${current.before.arrival} – ${current.before.departure} → ${current.arrival} – ${current.departure}`:current.note}\n\nThis changes the live PMS selected by your administrator.`))return;
update(await api<Change>(`/pms/changes/${current.id}/${name}`,'POST',{version:current.version,availabilityAndPriceChecked:checked}));
});}
return <div className="page pms-page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">Booking details, close at hand</span><h1>Reservations</h1><p>Look up a booking and review changes before applying them to your PMS.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh status</button></div>
<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||!canPropose}><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} · {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>;
}
function Reservation({value}:{value:Snapshot}){return <dl className="pms-reservation"><div><dt>Confirmation</dt><dd>{value.confirmation}</dd></div><div><dt>Guest</dt><dd>{value.guestName||'Not returned by PMS — verify guest identity there'}</dd></div><div><dt>Stay</dt><dd>{value.arrival} → {value.departure}</dd></div><div><dt>Room type</dt><dd>{value.roomType||'Not returned'}</dd></div><div><dt>Status</dt><dd>{value.status||'Not returned'}</dd></div><div><dt>Recorded total</dt><dd>{value.total||'Not returned — check in PMS'}</dd></div></dl>;}