diff --git a/backend/api/direct.py b/backend/api/direct.py index 2331aec..981dc19 100644 --- a/backend/api/direct.py +++ b/backend/api/direct.py @@ -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") diff --git a/frontend/src/pages/DirectRates.tsx b/frontend/src/pages/DirectRates.tsx index 34fb610..ec9cec3 100644 --- a/frontend/src/pages/DirectRates.tsx +++ b/frontend/src/pages/DirectRates.tsx @@ -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 + rate_labels: Record + room_order: string[] + benchmark_room: string | null + benchmark_rate: string | null + tier_base_room: string | null + tier_offsets: Record + 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({ + queryKey: ['direct-config', hotelId], + queryFn: () => api.get(`/direct/hotels/${hotelId}/config`).then(r => r.data), + }) + + const [benchRoom, setBenchRoom] = useState('') + const [benchRate, setBenchRate] = useState('') + const [baseRoom, setBaseRoom] = useState('') + const [offsets, setOffsets] = useState>({}) + const [roomNames, setRoomNames] = useState>({}) + const [rateNames, setRateNames] = useState>({}) + const [order, setOrder] = useState([]) + 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 = {} + 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 = {} + for (const [k, v] of Object.entries(roomNames)) if (v.trim()) room_labels[k] = v.trim() + const rate_labels: Record = {} + 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
Loading configuration…
+ } + + return ( +
+
+ Configure — {config.name} + +
+
+ + {config.room_ids.length === 0 && ( +
+ No room types known yet — run Discovery (or a scrape) first so room and rate IDs can be found. +
+ )} + + {config.room_ids.length > 0 && ( + <> +
+
Benchmark Rate
+
+ The room type + rate plan used as the consistent comparison rate across all dates. +
+
+
+ + +
+
+ + +
+
+
+ +
+
Tier Offsets
+
+ 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. +
+
+ + +
+ + + {config.room_ids.map(r => ( + + + + + ))} + +
+ {roomName(r)} + ({r}) + {r === baseRoom && base} + {r === benchRoom && benchmark} + + setOffsets(prev => ({ ...prev, [r]: e.target.value }))} + /> +
+
+ +
+
Room Type Names & Order
+
+ Friendly display names (blank = raw ID). Use the arrows to set the display order in the date view. +
+ + + {order.map((r, i) => ( + + + + + + ))} + +
+ + + {r} + setRoomNames(prev => ({ ...prev, [r]: e.target.value }))} + /> +
+
+ +
+
Rate Plan Names
+ + + {config.rate_ids.map(r => ( + + + + + ))} + +
{r} + setRateNames(prev => ({ ...prev, [r]: e.target.value }))} + /> +
+
+ +
+ + +
+ + )} +
+
+ ) +} + // ─── Manage Tab ─────────────────────────────────────────────────────────────── function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) { const [showAdd, setShowAdd] = useState(false) + const [configHotel, setConfigHotel] = useState(null) const [detectUrl, setDetectUrl] = useState('') const [detected, setDetected] = useState(null) const [newName, setNewName] = useState('') @@ -943,6 +1201,11 @@ function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () +
+ + {configHotel != null && ( + setConfigHotel(null)} /> + )} ) }