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:
parent
46020576f8
commit
029e3b379f
2 changed files with 309 additions and 2 deletions
|
|
@ -177,6 +177,46 @@ async def delete_hotel(hotel_id: int, user=Depends(get_current_user)):
|
|||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/hotels/{hotel_id}/discover")
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom'
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import Plot from 'react-plotly.js'
|
||||
import {
|
||||
Building2, RefreshCw, Plus, ChevronDown, ChevronRight,
|
||||
Clock, LineChart, X,
|
||||
Building2, RefreshCw, Plus, ChevronDown, ChevronRight, ChevronUp,
|
||||
Clock, LineChart, Settings2, X,
|
||||
} from 'lucide-react'
|
||||
import api from '../api'
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [configHotel, setConfigHotel] = useState<number | null>(null)
|
||||
const [detectUrl, setDetectUrl] = useState('')
|
||||
const [detected, setDetected] = useState<any>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
|
@ -943,6 +1201,11 @@ function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: ()
|
|||
</button>
|
||||
</td>
|
||||
<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"
|
||||
onClick={() => discoveryMutation.mutate(h.id)}
|
||||
disabled={discoveryMutation.isPending}>
|
||||
|
|
@ -956,6 +1219,10 @@ function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: ()
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configHotel != null && (
|
||||
<ConfigurePanel key={configHotel} hotelId={configHotel} onClose={() => setConfigHotel(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue