Add benchmark & tier-offset configuration UI to Direct Rates Manage tab

- new GET /direct/hotels/{id}/config returns full hotel config plus
  distinct room/rate IDs from scraped data (and any known via labels)
- Configure panel: benchmark room/rate selects, tier base room, per-room
  £ offsets (base locked to 0), friendly room/rate names, room display
  order (up/down) — saved via the existing PUT endpoint
- enables apples-to-apples estimated benchmark when only e.g. a suite
  is left: bench = room_price - room_offset + bench_offset

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 07:09:10 +00:00
parent 46020576f8
commit 029e3b379f
2 changed files with 309 additions and 2 deletions

View file

@ -177,6 +177,46 @@ async def delete_hotel(hotel_id: int, user=Depends(get_current_user)):
await db.commit() await db.commit()
@router.get("/hotels/{hotel_id}/config")
async def hotel_config(hotel_id: int, user=Depends(get_current_user)):
"""Full config for one hotel plus the room/rate IDs seen in scraped data,
for the benchmark / tier-offset / label configuration UI."""
require_cap(user, "manage_hotels")
async with AsyncSessionLocal() as db:
cfg_row = await db.execute(
text("""SELECT id, name, profile_name, params, room_labels, rate_labels,
room_order, benchmark_room, benchmark_rate, tier_base_room,
tier_offsets, scrape_enabled
FROM direct_competitor_hotels WHERE id = :id"""),
{"id": hotel_id}
)
hotel = cfg_row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
ids_result = await db.execute(
text("""SELECT DISTINCT room_id, rate_id FROM direct_rates
WHERE hotel_id = :hid"""),
{"hid": hotel_id}
)
pairs = ids_result.fetchall()
room_ids = sorted({p[0] for p in pairs})
rate_ids = sorted({p[1] for p in pairs})
# Include any rooms known only from labels/order (e.g. discovery without a scrape yet)
for rid in (hotel["room_labels"] or {}):
if rid not in room_ids:
room_ids.append(rid)
for rid in (hotel["rate_labels"] or {}):
if rid not in rate_ids:
rate_ids.append(rid)
out = dict(hotel)
out["room_ids"] = room_ids
out["rate_ids"] = rate_ids
return out
# ─── Discovery ─────────────────────────────────────────────────────────────── # ─── Discovery ───────────────────────────────────────────────────────────────
@router.post("/hotels/{hotel_id}/discover") @router.post("/hotels/{hotel_id}/discover")

View file

@ -3,8 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import Plot from 'react-plotly.js' import Plot from 'react-plotly.js'
import { import {
Building2, RefreshCw, Plus, ChevronDown, ChevronRight, Building2, RefreshCw, Plus, ChevronDown, ChevronRight, ChevronUp,
Clock, LineChart, X, Clock, LineChart, Settings2, X,
} from 'lucide-react' } from 'lucide-react'
import api from '../api' import api from '../api'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
@ -804,10 +804,268 @@ function RoomStatsTab({ hotels, selectedHotel, onSelectHotel }: {
) )
} }
// ─── Configure panel (benchmark, tier offsets, labels, order) ────────────────
interface HotelConfig {
id: number
name: string
profile_name: string
room_labels: Record<string, string>
rate_labels: Record<string, string>
room_order: string[]
benchmark_room: string | null
benchmark_rate: string | null
tier_base_room: string | null
tier_offsets: Record<string, number>
scrape_enabled: boolean
room_ids: string[]
rate_ids: string[]
}
const labelStyle: React.CSSProperties = {
fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4,
}
function ConfigurePanel({ hotelId, onClose }: { hotelId: number; onClose: () => void }) {
const qc = useQueryClient()
const { data: config, isLoading } = useQuery<HotelConfig>({
queryKey: ['direct-config', hotelId],
queryFn: () => api.get(`/direct/hotels/${hotelId}/config`).then(r => r.data),
})
const [benchRoom, setBenchRoom] = useState<string>('')
const [benchRate, setBenchRate] = useState<string>('')
const [baseRoom, setBaseRoom] = useState<string>('')
const [offsets, setOffsets] = useState<Record<string, string>>({})
const [roomNames, setRoomNames] = useState<Record<string, string>>({})
const [rateNames, setRateNames] = useState<Record<string, string>>({})
const [order, setOrder] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
if (config && !loaded) {
setBenchRoom(config.benchmark_room || '')
setBenchRate(config.benchmark_rate || '')
setBaseRoom(config.tier_base_room || '')
setOffsets(Object.fromEntries(
config.room_ids.map(r => [r, String(config.tier_offsets?.[r] ?? 0)])
))
setRoomNames(Object.fromEntries(
config.room_ids.map(r => [r, config.room_labels?.[r] || ''])
))
setRateNames(Object.fromEntries(
config.rate_ids.map(r => [r, config.rate_labels?.[r] || ''])
))
const ordered = (config.room_order || []).filter(r => config.room_ids.includes(r))
setOrder([...ordered, ...config.room_ids.filter(r => !ordered.includes(r))])
setLoaded(true)
}
const saveMutation = useMutation({
mutationFn: () => {
const tier_offsets: Record<string, number> = {}
for (const [k, v] of Object.entries(offsets)) {
const n = parseInt(v, 10)
if (!isNaN(n)) tier_offsets[k] = k === baseRoom ? 0 : n
}
const room_labels: Record<string, string> = {}
for (const [k, v] of Object.entries(roomNames)) if (v.trim()) room_labels[k] = v.trim()
const rate_labels: Record<string, string> = {}
for (const [k, v] of Object.entries(rateNames)) if (v.trim()) rate_labels[k] = v.trim()
return api.put(`/direct/hotels/${hotelId}`, {
benchmark_room: benchRoom || null,
benchmark_rate: benchRate || null,
tier_base_room: baseRoom || null,
tier_offsets,
room_labels,
rate_labels,
room_order: order,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['direct-hotels'] })
qc.invalidateQueries({ queryKey: ['direct-config', hotelId] })
qc.invalidateQueries({ queryKey: ['direct-dates'] })
qc.invalidateQueries({ queryKey: ['direct-rooms'] })
qc.invalidateQueries({ queryKey: ['direct-stats'] })
onClose()
},
})
const moveRoom = (rid: string, dir: -1 | 1) => {
setOrder(prev => {
const idx = prev.indexOf(rid)
const to = idx + dir
if (idx < 0 || to < 0 || to >= prev.length) return prev
const next = [...prev]
next[idx] = next[to]
next[to] = rid
return next
})
}
const roomName = (rid: string) => roomNames[rid]?.trim() || rid
if (isLoading || !config) {
return <div className="card"><div className="card-body loading-state"><div className="spinner" />Loading configuration</div></div>
}
return (
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Configure {config.name}
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)' }} onClick={onClose}>
<X size={16} strokeWidth={1.75} />
</button>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{config.room_ids.length === 0 && (
<div className="empty-state">
No room types known yet run Discovery (or a scrape) first so room and rate IDs can be found.
</div>
)}
{config.room_ids.length > 0 && (
<>
<div>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>Benchmark Rate</div>
<div style={{ fontSize: 12, color: 'var(--text-mid)', marginBottom: 10 }}>
The room type + rate plan used as the consistent comparison rate across all dates.
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div>
<label style={labelStyle}>Benchmark room type</label>
<select style={{ width: 240 }} value={benchRoom} onChange={e => setBenchRoom(e.target.value)}>
<option value=""> not set </option>
{config.room_ids.map(r => (
<option key={r} value={r}>{roomName(r)} ({r})</option>
))}
</select>
</div>
<div>
<label style={labelStyle}>Benchmark rate plan</label>
<select style={{ width: 240 }} value={benchRate} onChange={e => setBenchRate(e.target.value)}>
<option value=""> not set </option>
{config.rate_ids.map(r => (
<option key={r} value={r}>{rateNames[r]?.trim() || r}</option>
))}
</select>
</div>
</div>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>Tier Offsets</div>
<div style={{ fontSize: 12, color: 'var(--text-mid)', marginBottom: 10, maxWidth: 640 }}>
Fixed £ difference between room types, anchored to the tier base room. Used to estimate the
benchmark rate when the benchmark room isn't quoted (e.g. only a suite left) so comparisons
stay apples-to-apples. Positive = more expensive than base, negative = cheaper. The base room
itself is always 0.
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Tier base room (fixed anchor)</label>
<select style={{ width: 240 }} value={baseRoom} onChange={e => setBaseRoom(e.target.value)}>
<option value=""> none (offsets relative to benchmark) </option>
{config.room_ids.map(r => (
<option key={r} value={r}>{roomName(r)} ({r})</option>
))}
</select>
</div>
<table style={{ maxWidth: 520, fontSize: 12 }}>
<tbody>
{config.room_ids.map(r => (
<tr key={r}>
<td style={{ padding: '4px 8px 4px 0' }}>
{roomName(r)}
<span style={{ color: 'var(--text-mid)', fontSize: 11 }}> ({r})</span>
{r === baseRoom && <span className="badge badge-info" style={{ marginLeft: 6 }}>base</span>}
{r === benchRoom && <span className="badge badge-neutral" style={{ marginLeft: 6 }}>benchmark</span>}
</td>
<td style={{ padding: '4px 0', width: 120 }}>
<input
type="number" step={1} style={{ width: 110 }}
value={r === baseRoom ? '0' : (offsets[r] ?? '0')}
disabled={r === baseRoom}
onChange={e => setOffsets(prev => ({ ...prev, [r]: e.target.value }))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>Room Type Names & Order</div>
<div style={{ fontSize: 12, color: 'var(--text-mid)', marginBottom: 10 }}>
Friendly display names (blank = raw ID). Use the arrows to set the display order in the date view.
</div>
<table style={{ maxWidth: 560, fontSize: 12 }}>
<tbody>
{order.map((r, i) => (
<tr key={r}>
<td style={{ padding: '4px 0', width: 52 }}>
<button className="btn btn-outline btn-sm" style={{ padding: '2px 5px' }}
disabled={i === 0} onClick={() => moveRoom(r, -1)}>
<ChevronUp size={11} strokeWidth={1.75} />
</button>
<button className="btn btn-outline btn-sm" style={{ padding: '2px 5px', marginLeft: 3 }}
disabled={i === order.length - 1} onClick={() => moveRoom(r, 1)}>
<ChevronDown size={11} strokeWidth={1.75} />
</button>
</td>
<td style={{ padding: '4px 8px', color: 'var(--text-mid)', fontFamily: 'monospace', fontSize: 11 }}>{r}</td>
<td style={{ padding: '4px 0' }}>
<input
style={{ width: 240 }} placeholder={r}
value={roomNames[r] ?? ''}
onChange={e => setRoomNames(prev => ({ ...prev, [r]: e.target.value }))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>Rate Plan Names</div>
<table style={{ maxWidth: 560, fontSize: 12 }}>
<tbody>
{config.rate_ids.map(r => (
<tr key={r}>
<td style={{ padding: '4px 8px 4px 0', color: 'var(--text-mid)', fontFamily: 'monospace', fontSize: 11 }}>{r}</td>
<td style={{ padding: '4px 0' }}>
<input
style={{ width: 240 }} placeholder={r}
value={rateNames[r] ?? ''}
onChange={e => setRateNames(prev => ({ ...prev, [r]: e.target.value }))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving…' : 'Save Configuration'}
</button>
<button className="btn btn-outline" onClick={onClose}>Cancel</button>
</div>
</>
)}
</div>
</div>
)
}
// ─── Manage Tab ─────────────────────────────────────────────────────────────── // ─── Manage Tab ───────────────────────────────────────────────────────────────
function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) { function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) {
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [configHotel, setConfigHotel] = useState<number | null>(null)
const [detectUrl, setDetectUrl] = useState('') const [detectUrl, setDetectUrl] = useState('')
const [detected, setDetected] = useState<any>(null) const [detected, setDetected] = useState<any>(null)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
@ -943,6 +1201,11 @@ function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: ()
</button> </button>
</td> </td>
<td style={{ display: 'flex', gap: 6 }}> <td style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-outline btn-sm"
onClick={() => setConfigHotel(configHotel === h.id ? null : h.id)}>
<Settings2 size={12} strokeWidth={1.75} />
Configure
</button>
<button className="btn btn-outline btn-sm" <button className="btn btn-outline btn-sm"
onClick={() => discoveryMutation.mutate(h.id)} onClick={() => discoveryMutation.mutate(h.id)}
disabled={discoveryMutation.isPending}> disabled={discoveryMutation.isPending}>
@ -956,6 +1219,10 @@ function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: ()
</table> </table>
</div> </div>
</div> </div>
{configHotel != null && (
<ConfigurePanel key={configHotel} hotelId={configHotel} onClose={() => setConfigHotel(null)} />
)}
</div> </div>
) )
} }