Restore guestline-monitor features: room stock/occupancy, rate history, room stats
- dates endpoint now returns total_avail, benchmark rate (tier-resolved),
min-stay nights, max_rooms (summed per-room stock) and friendly labels
- rooms endpoint groups by room with stock, occupancy, best/bench rate and
nested rate plans; injects known-but-absent rooms; honours room_order
- new /history/{stay_date} endpoint: per room/rate series, benchmark mode,
or overall availability + cheapest rate per scrape run
- new /stats endpoint: per-room stock + windowed avg rate / current occ /
est. final occ (look-back window so past dates give completed bookings)
- DirectRates page: occupancy pills, avail x/stock, Room Stats tab,
history chart modal (plotly), expandable rate plans with history links
- fix discovery trigger crashing (run_until_complete inside running loop)
- fix migration script reading wrong column name (engine_profile)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
74ae94671b
commit
20d939de00
3 changed files with 908 additions and 173 deletions
|
|
@ -1,7 +1,6 @@
|
||||||
"""
|
"""
|
||||||
Direct booking engine API — hotel management, discovery, scrape control, rate data.
|
Direct booking engine API — hotel management, discovery, scrape control, rate data.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
@ -11,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from database import AsyncSessionLocal, SyncSessionLocal
|
from database import AsyncSessionLocal
|
||||||
from auth import get_current_user, require_cap
|
from auth import get_current_user, require_cap
|
||||||
from services.direct_profiles import PROFILES, detect_profile, get_profile
|
from services.direct_profiles import PROFILES, detect_profile, get_profile
|
||||||
from services.direct_scraper import run_discovery, get_discovery_status, run_scrape
|
from services.direct_scraper import run_discovery, get_discovery_status, run_scrape
|
||||||
|
|
@ -45,6 +44,37 @@ class DetectRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Benchmark helpers (ported from guestline-monitor) ──────────────────────
|
||||||
|
|
||||||
|
def _resolve_bench(prices: dict, bench_room: str, offsets: dict):
|
||||||
|
"""
|
||||||
|
Derive the benchmark room's price from whatever room prices are available.
|
||||||
|
`prices` maps room_id -> price on the benchmark rate plan.
|
||||||
|
All offsets are relative to tier_base_room; bench = room_price - room_offset + bench_offset.
|
||||||
|
Returns (price, is_calculated).
|
||||||
|
"""
|
||||||
|
if prices.get(bench_room) is not None:
|
||||||
|
return float(prices[bench_room]), False
|
||||||
|
|
||||||
|
bench_offset = offsets.get(bench_room, 0)
|
||||||
|
for room_id, room_offset in offsets.items():
|
||||||
|
if room_id == bench_room:
|
||||||
|
continue
|
||||||
|
if prices.get(room_id) is not None:
|
||||||
|
return float(prices[room_id]) - room_offset + bench_offset, True
|
||||||
|
|
||||||
|
for v in prices.values():
|
||||||
|
if v is not None:
|
||||||
|
return float(v), True
|
||||||
|
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def _room_stock(rows) -> dict:
|
||||||
|
"""room_id -> MAX(availability) ever seen = estimated stock."""
|
||||||
|
return {r["room_id"]: r["stock"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
# ─── Profiles ────────────────────────────────────────────────────────────────
|
# ─── Profiles ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/profiles")
|
@router.get("/profiles")
|
||||||
|
|
@ -161,10 +191,7 @@ async def trigger_discovery(hotel_id: int, background_tasks: BackgroundTasks, us
|
||||||
if not hotel:
|
if not hotel:
|
||||||
raise HTTPException(status_code=404, detail="Hotel not found")
|
raise HTTPException(status_code=404, detail="Hotel not found")
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(run_discovery, hotel_id, hotel["profile_name"], hotel["params"])
|
||||||
asyncio.get_event_loop().run_until_complete,
|
|
||||||
run_discovery(hotel_id, hotel["profile_name"], hotel["params"])
|
|
||||||
)
|
|
||||||
return {"status": "discovery started", "hotel_id": hotel_id}
|
return {"status": "discovery started", "hotel_id": hotel_id}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -220,37 +247,79 @@ async def hotel_dates(
|
||||||
if not hotel:
|
if not hotel:
|
||||||
raise HTTPException(status_code=404, detail="Hotel not found")
|
raise HTTPException(status_code=404, detail="Hotel not found")
|
||||||
|
|
||||||
# Latest snapshot per date: cheapest available price_incl
|
# Latest snapshot per (date, room, rate)
|
||||||
rates_result = await db.execute(
|
rates_result = await db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT
|
SELECT DISTINCT ON (stay_date, room_id, rate_id)
|
||||||
r.stay_date,
|
|
||||||
MIN(r.price_incl) FILTER (WHERE r.availability > 0) AS cheapest_rate,
|
|
||||||
BOOL_OR(r.availability > 0) AS has_availability,
|
|
||||||
BOOL_OR(r.min_stay_nights IS NOT NULL
|
|
||||||
AND r.min_stay_nights > 1) AS has_min_stay,
|
|
||||||
MAX(r.scraped_at) AS scraped_at
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT ON (room_id, rate_id)
|
|
||||||
stay_date, room_id, rate_id, availability,
|
stay_date, room_id, rate_id, availability,
|
||||||
price_incl, min_stay_nights, scraped_at
|
price_incl, min_stay_nights, scraped_at
|
||||||
FROM direct_rates
|
FROM direct_rates
|
||||||
WHERE hotel_id = :hid
|
WHERE hotel_id = :hid
|
||||||
AND stay_date BETWEEN :fd AND :td
|
AND stay_date BETWEEN :fd AND :td
|
||||||
ORDER BY room_id, rate_id, scraped_at DESC
|
ORDER BY stay_date, room_id, rate_id, scraped_at DESC
|
||||||
) r
|
|
||||||
GROUP BY r.stay_date
|
|
||||||
ORDER BY r.stay_date
|
|
||||||
"""),
|
"""),
|
||||||
{"hid": hotel_id, "fd": from_date, "td": to_date}
|
{"hid": hotel_id, "fd": from_date, "td": to_date}
|
||||||
)
|
)
|
||||||
dates = [dict(r) for r in rates_result.mappings().all()]
|
rows = rates_result.mappings().all()
|
||||||
|
|
||||||
|
# Stock = max availability ever seen per room (across all history)
|
||||||
|
stock_result = await db.execute(
|
||||||
|
text("""SELECT room_id, MAX(availability) AS stock
|
||||||
|
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
|
||||||
|
{"hid": hotel_id}
|
||||||
|
)
|
||||||
|
room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
|
||||||
|
|
||||||
|
bench_room = hotel["benchmark_room"] or ""
|
||||||
|
bench_rate = hotel["benchmark_rate"] or ""
|
||||||
|
offsets = hotel["tier_offsets"] or {}
|
||||||
|
max_rooms = sum(v for v in room_stock.values() if v) or None
|
||||||
|
|
||||||
|
# Group by date; availability counted once per room (max across its rate plans)
|
||||||
|
by_date: dict = {}
|
||||||
|
for r in rows:
|
||||||
|
d = by_date.setdefault(r["stay_date"], {
|
||||||
|
"room_avail": {}, "bench_prices": {}, "prices": [],
|
||||||
|
"min_stay": None, "scraped_at": None,
|
||||||
|
})
|
||||||
|
room_avail = d["room_avail"]
|
||||||
|
room_avail[r["room_id"]] = max(room_avail.get(r["room_id"], 0), r["availability"] or 0)
|
||||||
|
if r["price_incl"] is not None and (r["availability"] or 0) > 0:
|
||||||
|
d["prices"].append(float(r["price_incl"]))
|
||||||
|
if r["rate_id"] == bench_rate and r["price_incl"] is not None:
|
||||||
|
d["bench_prices"][r["room_id"]] = r["price_incl"]
|
||||||
|
if r["min_stay_nights"] and (d["min_stay"] is None or r["min_stay_nights"] > d["min_stay"]):
|
||||||
|
d["min_stay"] = r["min_stay_nights"]
|
||||||
|
if d["scraped_at"] is None or r["scraped_at"] > d["scraped_at"]:
|
||||||
|
d["scraped_at"] = r["scraped_at"]
|
||||||
|
|
||||||
|
dates = []
|
||||||
|
for stay_date in sorted(by_date):
|
||||||
|
d = by_date[stay_date]
|
||||||
|
total_avail = sum(d["room_avail"].values())
|
||||||
|
bench_price, bench_calc = _resolve_bench(d["bench_prices"], bench_room, offsets)
|
||||||
|
dates.append({
|
||||||
|
"stay_date": stay_date,
|
||||||
|
"total_avail": total_avail,
|
||||||
|
"cheapest_rate": min(d["prices"]) if d["prices"] else None,
|
||||||
|
"bench_rate": round(bench_price, 2) if bench_price is not None else None,
|
||||||
|
"bench_calculated": bench_calc,
|
||||||
|
"has_availability": total_avail > 0,
|
||||||
|
"min_stay_nights": d["min_stay"],
|
||||||
|
"has_min_stay": bool(d["min_stay"] and d["min_stay"] > 1),
|
||||||
|
"scraped_at": d["scraped_at"],
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"hotel_id": hotel_id,
|
"hotel_id": hotel_id,
|
||||||
"hotel_name": hotel["name"],
|
"hotel_name": hotel["name"],
|
||||||
"from_date": str(from_date),
|
"from_date": str(from_date),
|
||||||
"to_date": str(to_date),
|
"to_date": str(to_date),
|
||||||
|
"max_rooms": max_rooms,
|
||||||
|
"room_labels": hotel["room_labels"] or {},
|
||||||
|
"rate_labels": hotel["rate_labels"] or {},
|
||||||
|
"benchmark_room": bench_room or None,
|
||||||
|
"benchmark_rate": bench_rate or None,
|
||||||
"dates": dates,
|
"dates": dates,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,7 +333,7 @@ async def hotel_date_rooms(
|
||||||
require_cap(user, "view_direct_rates")
|
require_cap(user, "view_direct_rates")
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
cfg_row = await db.execute(
|
cfg_row = await db.execute(
|
||||||
text("SELECT room_labels, rate_labels, tier_offsets, tier_base_room, benchmark_room, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"),
|
text("SELECT room_labels, rate_labels, room_order, tier_offsets, tier_base_room, benchmark_room, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"),
|
||||||
{"id": hotel_id}
|
{"id": hotel_id}
|
||||||
)
|
)
|
||||||
hotel = cfg_row.mappings().fetchone()
|
hotel = cfg_row.mappings().fetchone()
|
||||||
|
|
@ -283,55 +352,280 @@ async def hotel_date_rooms(
|
||||||
"""),
|
"""),
|
||||||
{"hid": hotel_id, "sd": rate_date}
|
{"hid": hotel_id, "sd": rate_date}
|
||||||
)
|
)
|
||||||
rooms = [dict(r) for r in rooms_result.mappings().all()]
|
rows = [dict(r) for r in rooms_result.mappings().all()]
|
||||||
|
|
||||||
|
# Stock = max availability ever seen per room
|
||||||
|
stock_result = await db.execute(
|
||||||
|
text("""SELECT room_id, MAX(availability) AS stock
|
||||||
|
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
|
||||||
|
{"hid": hotel_id}
|
||||||
|
)
|
||||||
|
room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
|
||||||
|
|
||||||
# Resolve tier-normalised benchmark rates
|
|
||||||
room_labels = hotel["room_labels"] or {}
|
room_labels = hotel["room_labels"] or {}
|
||||||
rate_labels = hotel["rate_labels"] or {}
|
rate_labels = hotel["rate_labels"] or {}
|
||||||
tier_offsets = hotel["tier_offsets"] or {}
|
tier_offsets = hotel["tier_offsets"] or {}
|
||||||
tier_base_room = hotel["tier_base_room"]
|
benchmark_room = hotel["benchmark_room"] or ""
|
||||||
benchmark_room = hotel["benchmark_room"]
|
benchmark_rate = hotel["benchmark_rate"] or ""
|
||||||
benchmark_rate = hotel["benchmark_rate"]
|
|
||||||
|
|
||||||
# Find benchmark price
|
|
||||||
bench_price = None
|
|
||||||
if benchmark_room and benchmark_rate:
|
|
||||||
bench_match = next(
|
|
||||||
(r for r in rooms
|
|
||||||
if r["room_id"] == benchmark_room and r["rate_id"] == benchmark_rate
|
|
||||||
and r["availability"] > 0 and r["price_incl"]),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
if bench_match:
|
|
||||||
bench_price = float(bench_match["price_incl"])
|
|
||||||
elif tier_base_room and tier_offsets:
|
|
||||||
base_match = next(
|
|
||||||
(r for r in rooms
|
|
||||||
if r["room_id"] == tier_base_room and r["availability"] > 0 and r["price_incl"]),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
if base_match:
|
|
||||||
base_price = float(base_match["price_incl"])
|
|
||||||
bench_offset = tier_offsets.get(benchmark_room, 0)
|
bench_offset = tier_offsets.get(benchmark_room, 0)
|
||||||
base_offset = tier_offsets.get(tier_base_room, 0)
|
|
||||||
bench_price = base_price - base_offset + bench_offset
|
|
||||||
|
|
||||||
enriched = []
|
# Group flat room×rate rows into one entry per room
|
||||||
for r in rooms:
|
rooms: dict = {}
|
||||||
r["room_label"] = room_labels.get(r["room_id"], r["room_id"])
|
for r in rows:
|
||||||
r["rate_label"] = rate_labels.get(r["rate_id"], r["rate_id"])
|
rid = r["room_id"]
|
||||||
# Derive bench_rate for this room from tier offsets
|
room = rooms.setdefault(rid, {
|
||||||
r["bench_rate"] = None
|
"room_id": rid,
|
||||||
if bench_price is not None and tier_offsets and tier_base_room:
|
"room_label": room_labels.get(rid, rid),
|
||||||
room_offset = tier_offsets.get(r["room_id"])
|
"availability": r["availability"],
|
||||||
bench_offset = tier_offsets.get(benchmark_room, 0)
|
"stock": room_stock.get(rid),
|
||||||
if room_offset is not None:
|
"best_rate": None,
|
||||||
r["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2)
|
"bench_rate": None,
|
||||||
enriched.append(r)
|
"bench_calculated": False,
|
||||||
|
"min_stay_nights": r["min_stay_nights"],
|
||||||
|
"unavailable": False,
|
||||||
|
"rates": [],
|
||||||
|
})
|
||||||
|
room["availability"] = max(room["availability"] or 0, r["availability"] or 0)
|
||||||
|
if r["price_incl"] is not None:
|
||||||
|
p = float(r["price_incl"])
|
||||||
|
if room["best_rate"] is None or p < room["best_rate"]:
|
||||||
|
room["best_rate"] = p
|
||||||
|
if r["min_stay_nights"] and not room["min_stay_nights"]:
|
||||||
|
room["min_stay_nights"] = r["min_stay_nights"]
|
||||||
|
room["rates"].append({
|
||||||
|
"rate_id": r["rate_id"],
|
||||||
|
"rate_label": rate_labels.get(r["rate_id"], r["rate_id"]),
|
||||||
|
"price_incl": r["price_incl"],
|
||||||
|
"price_excl": r["price_excl"],
|
||||||
|
"min_stay_nights": r["min_stay_nights"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Resolve the benchmark room's price today from whatever rooms are present
|
||||||
|
bench_prices = {}
|
||||||
|
for rid, room in rooms.items():
|
||||||
|
p = next((rt["price_incl"] for rt in room["rates"] if rt["rate_id"] == benchmark_rate), None)
|
||||||
|
if p is not None:
|
||||||
|
bench_prices[rid] = p
|
||||||
|
bench_price, _ = _resolve_bench(bench_prices, benchmark_room, tier_offsets)
|
||||||
|
|
||||||
|
# Per-room benchmark: direct quote if present, else estimate via tier offsets
|
||||||
|
for rid, room in rooms.items():
|
||||||
|
direct = bench_prices.get(rid)
|
||||||
|
room_offset = tier_offsets.get(rid)
|
||||||
|
if direct is not None:
|
||||||
|
room["bench_rate"] = round(float(direct), 2)
|
||||||
|
elif bench_price is not None and room_offset is not None:
|
||||||
|
room["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2)
|
||||||
|
room["bench_calculated"] = True
|
||||||
|
|
||||||
|
# Inject known rooms absent from this date's snapshot (no availability at all)
|
||||||
|
for rid in set(room_stock) - set(rooms):
|
||||||
|
room_offset = tier_offsets.get(rid)
|
||||||
|
derived = round(bench_price + (room_offset - bench_offset), 2) \
|
||||||
|
if bench_price is not None and room_offset is not None else None
|
||||||
|
rooms[rid] = {
|
||||||
|
"room_id": rid,
|
||||||
|
"room_label": room_labels.get(rid, rid),
|
||||||
|
"availability": None,
|
||||||
|
"stock": room_stock.get(rid),
|
||||||
|
"best_rate": None,
|
||||||
|
"bench_rate": derived,
|
||||||
|
"bench_calculated": derived is not None,
|
||||||
|
"min_stay_nights": None,
|
||||||
|
"unavailable": True,
|
||||||
|
"rates": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sort by configured room_order, then by stock (largest first)
|
||||||
|
room_order = hotel["room_order"] or []
|
||||||
|
|
||||||
|
def sort_key(r):
|
||||||
|
try:
|
||||||
|
return (0, room_order.index(r["room_id"]), 0)
|
||||||
|
except ValueError:
|
||||||
|
return (1, -(r["stock"] or 0), 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"hotel_id": hotel_id,
|
"hotel_id": hotel_id,
|
||||||
"date": str(rate_date),
|
"date": str(rate_date),
|
||||||
"bench_price": bench_price,
|
"bench_price": bench_price,
|
||||||
"rooms": enriched,
|
"rooms": sorted(rooms.values(), key=sort_key),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Rate history ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/hotels/{hotel_id}/history/{stay_date}")
|
||||||
|
async def rate_history(
|
||||||
|
hotel_id: int,
|
||||||
|
stay_date: date,
|
||||||
|
room_id: Optional[str] = None,
|
||||||
|
rate_id: Optional[str] = None,
|
||||||
|
mode: Optional[str] = None,
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Time series of tracked rates for one stay date, across scrape runs.
|
||||||
|
- room_id + rate_id: history for that exact room/rate combo
|
||||||
|
- mode=benchmark: benchmark-room price history (tier-resolved)
|
||||||
|
- default: total availability + cheapest rate per scrape
|
||||||
|
"""
|
||||||
|
require_cap(user, "view_direct_rates")
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
cfg_row = await db.execute(
|
||||||
|
text("SELECT benchmark_room, benchmark_rate, tier_offsets 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")
|
||||||
|
|
||||||
|
if room_id and rate_id:
|
||||||
|
result = await db.execute(
|
||||||
|
text("""SELECT scraped_at, availability, price_incl
|
||||||
|
FROM direct_rates
|
||||||
|
WHERE hotel_id = :hid AND stay_date = :sd
|
||||||
|
AND room_id = :room AND rate_id = :rate
|
||||||
|
ORDER BY scraped_at"""),
|
||||||
|
{"hid": hotel_id, "sd": stay_date, "room": room_id, "rate": rate_id}
|
||||||
|
)
|
||||||
|
return [dict(r) for r in result.mappings().all()]
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
text("""SELECT scraped_at, room_id, rate_id, availability, price_incl
|
||||||
|
FROM direct_rates
|
||||||
|
WHERE hotel_id = :hid AND stay_date = :sd
|
||||||
|
ORDER BY scraped_at"""),
|
||||||
|
{"hid": hotel_id, "sd": stay_date}
|
||||||
|
)
|
||||||
|
rows = result.mappings().all()
|
||||||
|
|
||||||
|
bench_room = hotel["benchmark_room"] or ""
|
||||||
|
bench_rate = hotel["benchmark_rate"] or ""
|
||||||
|
offsets = hotel["tier_offsets"] or {}
|
||||||
|
|
||||||
|
# Group by scrape timestamp
|
||||||
|
by_scrape: dict = {}
|
||||||
|
for r in rows:
|
||||||
|
s = by_scrape.setdefault(r["scraped_at"], {"room_avail": {}, "bench_prices": {}, "prices": []})
|
||||||
|
s["room_avail"][r["room_id"]] = max(s["room_avail"].get(r["room_id"], 0), r["availability"] or 0)
|
||||||
|
if r["price_incl"] is not None:
|
||||||
|
s["prices"].append(float(r["price_incl"]))
|
||||||
|
if r["rate_id"] == bench_rate:
|
||||||
|
s["bench_prices"][r["room_id"]] = r["price_incl"]
|
||||||
|
|
||||||
|
series = []
|
||||||
|
for scraped_at in sorted(by_scrape):
|
||||||
|
s = by_scrape[scraped_at]
|
||||||
|
if mode == "benchmark":
|
||||||
|
price, calc = _resolve_bench(s["bench_prices"], bench_room, offsets)
|
||||||
|
series.append({
|
||||||
|
"scraped_at": scraped_at,
|
||||||
|
"price_incl": round(price, 2) if price is not None else None,
|
||||||
|
"calculated": calc,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
series.append({
|
||||||
|
"scraped_at": scraped_at,
|
||||||
|
"availability": sum(s["room_avail"].values()),
|
||||||
|
"price_incl": min(s["prices"]) if s["prices"] else None,
|
||||||
|
})
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Per-room stats ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
STAT_WINDOWS = {
|
||||||
|
"this_week": 7,
|
||||||
|
"this_month": 30,
|
||||||
|
"next_6mo": 182,
|
||||||
|
"next_12mo": 365,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hotels/{hotel_id}/stats")
|
||||||
|
async def hotel_stats(hotel_id: int, user=Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Per-room stats on the benchmark rate across time windows: stock (max
|
||||||
|
availability ever seen), avg rate, current occupancy vs stock, and
|
||||||
|
estimated final occupancy from the equivalent look-back window (past
|
||||||
|
dates no longer change, so they reflect completed bookings).
|
||||||
|
"""
|
||||||
|
require_cap(user, "view_direct_rates")
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
cfg_row = await db.execute(
|
||||||
|
text("SELECT room_labels, benchmark_rate 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")
|
||||||
|
|
||||||
|
stock_result = await db.execute(
|
||||||
|
text("""SELECT room_id, MAX(availability) AS stock
|
||||||
|
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
|
||||||
|
{"hid": hotel_id}
|
||||||
|
)
|
||||||
|
stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
|
||||||
|
|
||||||
|
bench_rate = hotel["benchmark_rate"] or ""
|
||||||
|
result = await db.execute(
|
||||||
|
text("""SELECT DISTINCT ON (stay_date, room_id)
|
||||||
|
stay_date, room_id, availability, price_incl
|
||||||
|
FROM direct_rates
|
||||||
|
WHERE hotel_id = :hid AND rate_id = :rate
|
||||||
|
AND stay_date BETWEEN :fd AND :td
|
||||||
|
ORDER BY stay_date, room_id, scraped_at DESC"""),
|
||||||
|
{"hid": hotel_id, "rate": bench_rate,
|
||||||
|
"fd": today - timedelta(days=365), "td": today + timedelta(days=365)}
|
||||||
|
)
|
||||||
|
rows = result.mappings().all()
|
||||||
|
|
||||||
|
room_labels = hotel["room_labels"] or {}
|
||||||
|
by_room: dict = {}
|
||||||
|
for r in rows:
|
||||||
|
by_room.setdefault(r["room_id"], []).append(r)
|
||||||
|
|
||||||
|
result_out = {}
|
||||||
|
for room_id, room_stock_val in stock.items():
|
||||||
|
room_rows = by_room.get(room_id, [])
|
||||||
|
room_stock_safe = room_stock_val or 1
|
||||||
|
windows = {}
|
||||||
|
for wname, days in STAT_WINDOWS.items():
|
||||||
|
future = [r for r in room_rows if today <= r["stay_date"] < today + timedelta(days=days)]
|
||||||
|
past = [r for r in room_rows if today - timedelta(days=days) <= r["stay_date"] < today]
|
||||||
|
|
||||||
|
if not future and not past:
|
||||||
|
windows[wname] = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
prices = [float(r["price_incl"]) for r in future if r["price_incl"] is not None]
|
||||||
|
avg_price = round(sum(prices) / len(prices), 2) if prices else None
|
||||||
|
|
||||||
|
avg_occ = None
|
||||||
|
if future:
|
||||||
|
avg_avail = sum(r["availability"] or 0 for r in future) / len(future)
|
||||||
|
avg_occ = round((1 - avg_avail / room_stock_safe) * 100, 1)
|
||||||
|
|
||||||
|
est_final_occ = None
|
||||||
|
if past:
|
||||||
|
past_sold = sum(room_stock_safe - (r["availability"] or 0) for r in past)
|
||||||
|
est_final_occ = round(past_sold / (len(past) * room_stock_safe) * 100, 1)
|
||||||
|
|
||||||
|
windows[wname] = {
|
||||||
|
"dates": len(future),
|
||||||
|
"avg_price": avg_price,
|
||||||
|
"avg_occ": avg_occ,
|
||||||
|
"past_dates": len(past),
|
||||||
|
"est_final_occ": est_final_occ,
|
||||||
|
}
|
||||||
|
|
||||||
|
result_out[room_id] = {
|
||||||
|
"stock": room_stock_val,
|
||||||
|
"room_label": room_labels.get(room_id, room_id),
|
||||||
|
"windows": windows,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result_out
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
ARCHIVE_DIR = os.environ.get(
|
ARCHIVE_DIR = os.environ.get(
|
||||||
"GUESTLINE_ARCHIVE",
|
"GUESTLINE_ARCHIVE",
|
||||||
|
|
@ -37,7 +38,7 @@ def migrate():
|
||||||
src = sqlite3.connect(CONFIG_DB)
|
src = sqlite3.connect(CONFIG_DB)
|
||||||
src.row_factory = dict_row
|
src.row_factory = dict_row
|
||||||
hotels = src.execute(
|
hotels = src.execute(
|
||||||
"""SELECT id, name, profile, params, room_labels, rate_labels,
|
"""SELECT id, name, engine_profile, params, room_labels, rate_labels,
|
||||||
room_order, benchmark_room, benchmark_rate,
|
room_order, benchmark_room, benchmark_rate,
|
||||||
tier_base_room, tier_offsets, scrape_enabled, last_scraped_at
|
tier_base_room, tier_offsets, scrape_enabled, last_scraped_at
|
||||||
FROM hotels ORDER BY id"""
|
FROM hotels ORDER BY id"""
|
||||||
|
|
@ -65,7 +66,7 @@ def migrate():
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
h["name"],
|
h["name"],
|
||||||
h["profile"],
|
h["engine_profile"],
|
||||||
params,
|
params,
|
||||||
h["room_labels"] or "{}",
|
h["room_labels"] or "{}",
|
||||||
h["rate_labels"] or "{}",
|
h["rate_labels"] or "{}",
|
||||||
|
|
@ -163,5 +164,4 @@ def migrate():
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import psycopg2.extras
|
|
||||||
migrate()
|
migrate()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
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 {
|
import {
|
||||||
Building2, RefreshCw, Plus, Settings, ChevronDown, ChevronRight,
|
Building2, RefreshCw, Plus, ChevronDown, ChevronRight,
|
||||||
AlertCircle, CheckCircle, Clock,
|
Clock, LineChart, 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'
|
||||||
|
|
@ -12,8 +13,11 @@ import { can } from '../types'
|
||||||
const fmtDate = (d: Date) =>
|
const fmtDate = (d: Date) =>
|
||||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
const TABS = ['overview', 'hotel', 'manage'] as const
|
const TABS = ['overview', 'hotel', 'stats', 'manage'] as const
|
||||||
type Tab = typeof TABS[number]
|
type Tab = typeof TABS[number]
|
||||||
|
const TAB_LABELS: Record<Tab, string> = {
|
||||||
|
overview: 'Overview', hotel: 'Hotel Detail', stats: 'Room Stats', manage: 'Manage',
|
||||||
|
}
|
||||||
|
|
||||||
interface DirectHotel {
|
interface DirectHotel {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -27,30 +31,115 @@ interface DirectHotel {
|
||||||
|
|
||||||
interface DateRow {
|
interface DateRow {
|
||||||
stay_date: string
|
stay_date: string
|
||||||
|
total_avail: number
|
||||||
cheapest_rate: number | null
|
cheapest_rate: number | null
|
||||||
|
bench_rate: number | null
|
||||||
|
bench_calculated: boolean
|
||||||
has_availability: boolean
|
has_availability: boolean
|
||||||
|
min_stay_nights: number | null
|
||||||
has_min_stay: boolean
|
has_min_stay: boolean
|
||||||
scraped_at: string | null
|
scraped_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomRow {
|
interface DatesResponse {
|
||||||
room_id: string
|
hotel_name: string
|
||||||
rate_id: string
|
max_rooms: number | null
|
||||||
room_label: string
|
benchmark_room: string | null
|
||||||
rate_label: string
|
benchmark_rate: string | null
|
||||||
availability: number
|
room_labels: Record<string, string>
|
||||||
price_incl: number | null
|
rate_labels: Record<string, string>
|
||||||
min_stay_nights: number | null
|
dates: DateRow[]
|
||||||
bench_rate: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmt = (v: number | null) => v != null ? `£${v.toFixed(2)}` : '—'
|
interface RateEntry {
|
||||||
|
rate_id: string
|
||||||
|
rate_label: string
|
||||||
|
price_incl: number | null
|
||||||
|
price_excl: number | null
|
||||||
|
min_stay_nights: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomGroup {
|
||||||
|
room_id: string
|
||||||
|
room_label: string
|
||||||
|
availability: number | null
|
||||||
|
stock: number | null
|
||||||
|
best_rate: number | null
|
||||||
|
bench_rate: number | null
|
||||||
|
bench_calculated: boolean
|
||||||
|
min_stay_nights: number | null
|
||||||
|
unavailable: boolean
|
||||||
|
rates: RateEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryPoint {
|
||||||
|
scraped_at: string
|
||||||
|
availability?: number
|
||||||
|
price_incl: number | null
|
||||||
|
calculated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsWindow {
|
||||||
|
dates: number
|
||||||
|
avg_price: number | null
|
||||||
|
avg_occ: number | null
|
||||||
|
past_dates: number
|
||||||
|
est_final_occ: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomStats {
|
||||||
|
stock: number | null
|
||||||
|
room_label: string
|
||||||
|
windows: Record<string, StatsWindow | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryTarget {
|
||||||
|
date: string
|
||||||
|
roomId?: string
|
||||||
|
rateId?: string
|
||||||
|
mode: 'overall' | 'benchmark' | 'rate'
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
const fmt = (v: number | null | undefined) => v != null ? `£${Number(v).toFixed(2)}` : '—'
|
||||||
const age = (ts: string | null) => {
|
const age = (ts: string | null) => {
|
||||||
if (!ts) return 'Never'
|
if (!ts) return 'Never'
|
||||||
const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000)
|
const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000)
|
||||||
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
|
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const occColor = (pct: number) =>
|
||||||
|
pct >= 80 ? 'var(--danger)' : pct >= 50 ? 'var(--warning)' : 'var(--success)'
|
||||||
|
|
||||||
|
function OccupancyPill({ avail, stock }: { avail: number; stock: number | null }) {
|
||||||
|
if (!stock) return null
|
||||||
|
const pct = Math.round((1 - avail / stock) * 100)
|
||||||
|
return (
|
||||||
|
<span className="badge" style={{
|
||||||
|
background: pct >= 80 ? '#fee2e2' : pct >= 50 ? '#fef3c7' : '#dcfce7',
|
||||||
|
color: occColor(pct),
|
||||||
|
}}>
|
||||||
|
{pct}% sold
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OccBar({ pct }: { pct: number | null }) {
|
||||||
|
if (pct == null) return null
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 70, height: 6, background: 'var(--border)',
|
||||||
|
borderRadius: 3, verticalAlign: 'middle', marginLeft: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
display: 'block', height: '100%', borderRadius: 3,
|
||||||
|
width: `${Math.min(100, pct)}%`, background: occColor(pct),
|
||||||
|
}} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DirectRates() {
|
export default function DirectRates() {
|
||||||
const { hotelId } = useParams<{ hotelId?: string }>()
|
const { hotelId } = useParams<{ hotelId?: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -62,13 +151,14 @@ export default function DirectRates() {
|
||||||
const [expandedDate, setExpandedDate] = useState<string | null>(null)
|
const [expandedDate, setExpandedDate] = useState<string | null>(null)
|
||||||
const [fromDate, setFromDate] = useState(fmtDate(new Date()))
|
const [fromDate, setFromDate] = useState(fmtDate(new Date()))
|
||||||
const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000)))
|
const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000)))
|
||||||
|
const [history, setHistory] = useState<HistoryTarget | null>(null)
|
||||||
|
|
||||||
const { data: hotels, isLoading: hotelsLoading } = useQuery<DirectHotel[]>({
|
const { data: hotels, isLoading: hotelsLoading } = useQuery<DirectHotel[]>({
|
||||||
queryKey: ['direct-hotels'],
|
queryKey: ['direct-hotels'],
|
||||||
queryFn: () => api.get('/direct/hotels').then(r => r.data),
|
queryFn: () => api.get('/direct/hotels').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: dates, isLoading: datesLoading } = useQuery<{ dates: DateRow[]; hotel_name: string }>({
|
const { data: dates, isLoading: datesLoading } = useQuery<DatesResponse>({
|
||||||
queryKey: ['direct-dates', selectedHotel, fromDate, toDate],
|
queryKey: ['direct-dates', selectedHotel, fromDate, toDate],
|
||||||
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, {
|
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, {
|
||||||
params: { from_date: fromDate, to_date: toDate }
|
params: { from_date: fromDate, to_date: toDate }
|
||||||
|
|
@ -76,7 +166,7 @@ export default function DirectRates() {
|
||||||
enabled: !!selectedHotel && tab === 'hotel',
|
enabled: !!selectedHotel && tab === 'hotel',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: roomData } = useQuery<{ rooms: RoomRow[]; bench_price: number | null }>({
|
const { data: roomData } = useQuery<{ rooms: RoomGroup[]; bench_price: number | null }>({
|
||||||
queryKey: ['direct-rooms', selectedHotel, expandedDate],
|
queryKey: ['direct-rooms', selectedHotel, expandedDate],
|
||||||
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data),
|
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data),
|
||||||
enabled: !!selectedHotel && !!expandedDate,
|
enabled: !!selectedHotel && !!expandedDate,
|
||||||
|
|
@ -90,7 +180,6 @@ export default function DirectRates() {
|
||||||
|
|
||||||
const selectHotel = (id: number) => {
|
const selectHotel = (id: number) => {
|
||||||
setSelectedHotel(id)
|
setSelectedHotel(id)
|
||||||
setTab('hotel')
|
|
||||||
setExpandedDate(null)
|
setExpandedDate(null)
|
||||||
navigate(`/direct/${id}`)
|
navigate(`/direct/${id}`)
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +202,7 @@ export default function DirectRates() {
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button key={t} className={`sub-nav-item${tab === t ? ' active' : ''}`}
|
<button key={t} className={`sub-nav-item${tab === t ? ' active' : ''}`}
|
||||||
onClick={() => setTab(t)}>
|
onClick={() => setTab(t)}>
|
||||||
{t === 'overview' ? 'Overview' : t === 'hotel' ? 'Hotel Detail' : 'Manage'}
|
{TAB_LABELS[t]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -122,7 +211,7 @@ export default function DirectRates() {
|
||||||
<OverviewTab
|
<OverviewTab
|
||||||
hotels={hotels || []}
|
hotels={hotels || []}
|
||||||
loading={hotelsLoading}
|
loading={hotelsLoading}
|
||||||
onSelectHotel={selectHotel}
|
onSelectHotel={id => { selectHotel(id); setTab('hotel') }}
|
||||||
onScrape={id => scrapeMutation.mutate(id)}
|
onScrape={id => scrapeMutation.mutate(id)}
|
||||||
scraping={scrapeMutation.isPending}
|
scraping={scrapeMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
@ -133,8 +222,7 @@ export default function DirectRates() {
|
||||||
hotels={hotels || []}
|
hotels={hotels || []}
|
||||||
selectedHotel={selectedHotel}
|
selectedHotel={selectedHotel}
|
||||||
onSelectHotel={selectHotel}
|
onSelectHotel={selectHotel}
|
||||||
dates={dates?.dates || []}
|
data={dates}
|
||||||
hotelName={dates?.hotel_name}
|
|
||||||
datesLoading={datesLoading}
|
datesLoading={datesLoading}
|
||||||
fromDate={fromDate}
|
fromDate={fromDate}
|
||||||
toDate={toDate}
|
toDate={toDate}
|
||||||
|
|
@ -144,6 +232,15 @@ export default function DirectRates() {
|
||||||
expandedDate={expandedDate}
|
expandedDate={expandedDate}
|
||||||
setExpandedDate={setExpandedDate}
|
setExpandedDate={setExpandedDate}
|
||||||
roomData={roomData}
|
roomData={roomData}
|
||||||
|
onShowHistory={setHistory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'stats' && (
|
||||||
|
<RoomStatsTab
|
||||||
|
hotels={hotels || []}
|
||||||
|
selectedHotel={selectedHotel}
|
||||||
|
onSelectHotel={selectHotel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -153,6 +250,10 @@ export default function DirectRates() {
|
||||||
{tab === 'manage' && !canManage && (
|
{tab === 'manage' && !canManage && (
|
||||||
<div className="empty-state">You don't have permission to manage competitor hotels.</div>
|
<div className="empty-state">You don't have permission to manage competitor hotels.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{history && selectedHotel && (
|
||||||
|
<HistoryModal hotelId={selectedHotel} target={history} onClose={() => setHistory(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -222,15 +323,14 @@ function OverviewTab({ hotels, loading, onSelectHotel, onScrape, scraping }: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Hotel Detail Tab ─────────────────────────────────────────────────────────
|
// ─── Hotel selector row ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName, datesLoading,
|
|
||||||
fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate, roomData }: any) {
|
|
||||||
|
|
||||||
|
function HotelSelector({ hotels, selectedHotel, onSelectHotel }: {
|
||||||
|
hotels: DirectHotel[]
|
||||||
|
selectedHotel: number | null
|
||||||
|
onSelectHotel: (id: number) => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
{/* Controls */}
|
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label>
|
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -239,11 +339,42 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
|
||||||
onChange={e => onSelectHotel(parseInt(e.target.value))}
|
onChange={e => onSelectHotel(parseInt(e.target.value))}
|
||||||
>
|
>
|
||||||
<option value="">Select a hotel…</option>
|
<option value="">Select a hotel…</option>
|
||||||
{hotels.map((h: DirectHotel) => (
|
{hotels.map(h => (
|
||||||
<option key={h.id} value={h.id}>{h.name}</option>
|
<option key={h.id} value={h.id}>{h.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hotel Detail Tab ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, data, datesLoading,
|
||||||
|
fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate,
|
||||||
|
roomData, onShowHistory }: {
|
||||||
|
hotels: DirectHotel[]
|
||||||
|
selectedHotel: number | null
|
||||||
|
onSelectHotel: (id: number) => void
|
||||||
|
data: DatesResponse | undefined
|
||||||
|
datesLoading: boolean
|
||||||
|
fromDate: string
|
||||||
|
toDate: string
|
||||||
|
setFromDate: (v: string) => void
|
||||||
|
setToDate: (v: string) => void
|
||||||
|
presets: { label: string; days: number }[]
|
||||||
|
expandedDate: string | null
|
||||||
|
setExpandedDate: (v: string | null) => void
|
||||||
|
roomData: { rooms: RoomGroup[]; bench_price: number | null } | undefined
|
||||||
|
onShowHistory: (t: HistoryTarget) => void
|
||||||
|
}) {
|
||||||
|
const dates = data?.dates || []
|
||||||
|
const maxRooms = data?.max_rooms || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<HotelSelector hotels={hotels} selectedHotel={selectedHotel} onSelectHotel={onSelectHotel} />
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>From</label>
|
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>From</label>
|
||||||
<input type="date" style={{ width: 140 }} value={fromDate} onChange={e => setFromDate(e.target.value)} />
|
<input type="date" style={{ width: 140 }} value={fromDate} onChange={e => setFromDate(e.target.value)} />
|
||||||
|
|
@ -253,7 +384,7 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
|
||||||
<input type="date" style={{ width: 140 }} value={toDate} onChange={e => setToDate(e.target.value)} />
|
<input type="date" style={{ width: 140 }} value={toDate} onChange={e => setToDate(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
{presets.map((p: any) => (
|
{presets.map(p => (
|
||||||
<button key={p.label} className="btn btn-outline btn-sm"
|
<button key={p.label} className="btn btn-outline btn-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const from = new Date(); const to = new Date(Date.now() + p.days * 86400000)
|
const from = new Date(); const to = new Date(Date.now() + p.days * 86400000)
|
||||||
|
|
@ -273,21 +404,33 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
|
||||||
|
|
||||||
{selectedHotel && !datesLoading && dates.length > 0 && (
|
{selectedHotel && !datesLoading && dates.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">{hotelName} — {dates.length} dates</div>
|
<div className="card-header">
|
||||||
|
{data?.hotel_name} — {dates.length} dates
|
||||||
|
{maxRooms ? <span style={{ fontWeight: 400, color: 'var(--text-mid)', fontSize: 12, marginLeft: 8 }}>· est. {maxRooms} rooms total stock</span> : null}
|
||||||
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Cheapest Rate</th>
|
<th>Day</th>
|
||||||
<th>Availability</th>
|
<th>Avail</th>
|
||||||
<th>Min-Stay</th>
|
<th>Occupancy</th>
|
||||||
|
<th>Best Rate</th>
|
||||||
|
<th>Benchmark</th>
|
||||||
<th>Scraped</th>
|
<th>Scraped</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{dates.map((d: DateRow) => (
|
{dates.map(d => {
|
||||||
|
const day = new Date(d.stay_date + 'T12:00:00')
|
||||||
|
const dow = DAYS[day.getDay()]
|
||||||
|
const isWeekend = day.getDay() === 0 || day.getDay() === 5 || day.getDay() === 6
|
||||||
|
const isMinStay = !!(d.min_stay_nights && d.min_stay_nights > 1)
|
||||||
|
const isFull = d.total_avail === 0 && !isMinStay
|
||||||
|
return (
|
||||||
<React.Fragment key={d.stay_date}>
|
<React.Fragment key={d.stay_date}>
|
||||||
<tr
|
<tr
|
||||||
onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)}
|
onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)}
|
||||||
|
|
@ -298,79 +441,377 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
|
||||||
? <ChevronDown size={14} strokeWidth={1.75} />
|
? <ChevronDown size={14} strokeWidth={1.75} />
|
||||||
: <ChevronRight size={14} strokeWidth={1.75} />}
|
: <ChevronRight size={14} strokeWidth={1.75} />}
|
||||||
</td>
|
</td>
|
||||||
<td>{d.stay_date}</td>
|
<td style={{ fontWeight: 600 }}>{d.stay_date}</td>
|
||||||
|
<td style={{ color: isWeekend ? 'var(--gold)' : 'var(--text-mid)', fontSize: 12 }}>{dow}</td>
|
||||||
|
<td>
|
||||||
|
{isFull
|
||||||
|
? <span className="badge badge-danger">FULL</span>
|
||||||
|
: isMinStay
|
||||||
|
? <span className="badge badge-warning">{d.min_stay_nights}N min</span>
|
||||||
|
: <strong>{d.total_avail}</strong>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isFull
|
||||||
|
? <span className="badge badge-danger">Sold Out</span>
|
||||||
|
: isMinStay
|
||||||
|
? <span className="badge badge-warning">Min stay</span>
|
||||||
|
: <OccupancyPill avail={d.total_avail} stock={maxRooms} />}
|
||||||
|
</td>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
{d.cheapest_rate ? `£${Number(d.cheapest_rate).toFixed(2)}` : '—'}
|
{(isFull || isMinStay) && d.cheapest_rate != null
|
||||||
|
? <span><span style={{ color: 'var(--text-mid)', fontSize: 11 }}>est. </span><em>{fmt(d.cheapest_rate)}</em></span>
|
||||||
|
: fmt(d.cheapest_rate)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td style={{ color: 'var(--text-mid)' }}>
|
||||||
{d.has_availability
|
{d.bench_rate != null
|
||||||
? <CheckCircle size={14} strokeWidth={1.75} color="var(--success)" />
|
? (d.bench_calculated || isMinStay ? <em>{fmt(d.bench_rate)}*</em> : fmt(d.bench_rate))
|
||||||
: <AlertCircle size={14} strokeWidth={1.75} color="var(--danger)" />}
|
: '—'}
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{d.has_min_stay
|
|
||||||
? <span className="badge badge-warning">Min-stay</span>
|
|
||||||
: null}
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td>
|
<td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td>
|
||||||
|
<td style={{ width: 60 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
title="Rate & availability history"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onShowHistory({ date: d.stay_date, mode: 'overall', title: `History — ${d.stay_date}` })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LineChart size={12} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expandedDate === d.stay_date && roomData && (
|
{expandedDate === d.stay_date && roomData && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} style={{ background: '#f8fafc', padding: '8px 16px' }}>
|
<td colSpan={9} style={{ background: '#f8fafc', padding: '8px 16px' }}>
|
||||||
<RoomBreakdown rooms={roomData.rooms} benchPrice={roomData.bench_price} />
|
<RoomBreakdown
|
||||||
|
rooms={roomData.rooms}
|
||||||
|
date={d.stay_date}
|
||||||
|
onShowHistory={onShowHistory}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ padding: '8px 16px', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
|
* benchmark estimated from another room type via tier offsets. Occupancy is measured against the
|
||||||
|
highest availability ever observed per room (estimated stock).
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomBreakdown({ rooms, benchPrice }: { rooms: RoomRow[]; benchPrice: number | null }) {
|
function RoomBreakdown({ rooms, date, onShowHistory }: {
|
||||||
|
rooms: RoomGroup[]
|
||||||
|
date: string
|
||||||
|
onShowHistory: (t: HistoryTarget) => void
|
||||||
|
}) {
|
||||||
|
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table style={{ width: '100%', fontSize: 12 }}>
|
<table style={{ width: '100%', fontSize: 12 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th></th>
|
||||||
<th>Room</th>
|
<th>Room</th>
|
||||||
<th>Rate Plan</th>
|
|
||||||
<th>Avail</th>
|
<th>Avail</th>
|
||||||
<th>Price</th>
|
<th>Occupancy</th>
|
||||||
|
<th>Best Rate</th>
|
||||||
<th>Bench Rate</th>
|
<th>Bench Rate</th>
|
||||||
<th>Min-Stay</th>
|
<th>Min-Stay</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rooms.map((r, i) => (
|
{rooms.map(r => {
|
||||||
<tr key={i}>
|
const roomFull = !r.unavailable && r.availability === 0
|
||||||
<td>{r.room_label}</td>
|
const minStay = !!(r.min_stay_nights && r.min_stay_nights > 1)
|
||||||
<td style={{ color: 'var(--text-mid)' }}>{r.rate_label}</td>
|
const expanded = expandedRoom === r.room_id
|
||||||
<td>{r.availability > 0 ? <CheckCircle size={12} strokeWidth={1.75} color="var(--success)" /> : <AlertCircle size={12} strokeWidth={1.75} color="var(--danger)" />}</td>
|
return (
|
||||||
<td style={{ fontWeight: 600 }}>{fmt(r.price_incl)}</td>
|
<React.Fragment key={r.room_id}>
|
||||||
<td style={{ color: 'var(--text-mid)' }}>{fmt(r.bench_rate)}</td>
|
<tr
|
||||||
<td>{r.min_stay_nights && r.min_stay_nights > 1 ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td>
|
onClick={() => !r.unavailable && setExpandedRoom(expanded ? null : r.room_id)}
|
||||||
|
style={{ cursor: r.unavailable ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<td style={{ width: 20, color: 'var(--text-mid)' }}>
|
||||||
|
{!r.unavailable && (expanded
|
||||||
|
? <ChevronDown size={12} strokeWidth={1.75} />
|
||||||
|
: <ChevronRight size={12} strokeWidth={1.75} />)}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontWeight: 600 }}>{r.room_label}</td>
|
||||||
|
<td>
|
||||||
|
{r.unavailable
|
||||||
|
? <span className="badge badge-neutral">N/A</span>
|
||||||
|
: roomFull
|
||||||
|
? <span className="badge badge-danger">FULL</span>
|
||||||
|
: minStay
|
||||||
|
? <span className="badge badge-warning">{r.min_stay_nights}N min</span>
|
||||||
|
: <span><strong>{r.availability}</strong>{r.stock ? <span style={{ color: 'var(--text-mid)' }}> / {r.stock}</span> : null}</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{roomFull
|
||||||
|
? <span className="badge badge-danger">Sold Out</span>
|
||||||
|
: (!r.unavailable && !minStay && r.availability != null)
|
||||||
|
? <OccupancyPill avail={r.availability} stock={r.stock} />
|
||||||
|
: null}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontWeight: 600 }}>
|
||||||
|
{(roomFull || minStay) && r.best_rate != null
|
||||||
|
? <span><span style={{ color: 'var(--text-mid)', fontSize: 11 }}>est. </span><em>{fmt(r.best_rate)}</em></span>
|
||||||
|
: fmt(r.best_rate)}
|
||||||
|
</td>
|
||||||
|
<td style={{ color: 'var(--text-mid)' }}>
|
||||||
|
{r.bench_rate != null
|
||||||
|
? (r.bench_calculated ? <em>{fmt(r.bench_rate)}*</em> : fmt(r.bench_rate))
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td>{minStay ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td>
|
||||||
|
</tr>
|
||||||
|
{expanded && r.rates.map(rate => (
|
||||||
|
<tr key={rate.rate_id} style={{ background: '#f1f5f9' }}>
|
||||||
|
<td></td>
|
||||||
|
<td style={{ paddingLeft: 20, color: 'var(--text-mid)' }}>{rate.rate_label}</td>
|
||||||
|
<td colSpan={2}></td>
|
||||||
|
<td>{fmt(rate.price_incl)} incl <span style={{ color: 'var(--text-mid)' }}>/ {fmt(rate.price_excl)} excl</span></td>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, padding: 0 }}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onShowHistory({
|
||||||
|
date, roomId: r.room_id, rateId: rate.rate_id, mode: 'rate',
|
||||||
|
title: `${r.room_label} — ${rate.rate_label} — ${date}`,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LineChart size={11} strokeWidth={1.75} />
|
||||||
|
history
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── History Modal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PLOT_LAYOUT_BASE = {
|
||||||
|
paper_bgcolor: 'transparent',
|
||||||
|
plot_bgcolor: 'transparent',
|
||||||
|
font: { family: 'Inter, system-ui, sans-serif', size: 12, color: '#60748b' },
|
||||||
|
margin: { t: 20, r: 48, b: 48, l: 48 },
|
||||||
|
xaxis: { gridcolor: '#e5e9f0', zeroline: false },
|
||||||
|
yaxis: { gridcolor: '#e5e9f0', zeroline: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryModal({ hotelId, target, onClose }: {
|
||||||
|
hotelId: number
|
||||||
|
target: HistoryTarget
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { data: series, isLoading } = useQuery<HistoryPoint[]>({
|
||||||
|
queryKey: ['direct-history', hotelId, target.date, target.roomId, target.rateId, target.mode],
|
||||||
|
queryFn: () => api.get(`/direct/hotels/${hotelId}/history/${target.date}`, {
|
||||||
|
params: {
|
||||||
|
...(target.roomId ? { room_id: target.roomId } : {}),
|
||||||
|
...(target.rateId ? { rate_id: target.rateId } : {}),
|
||||||
|
...(target.mode === 'benchmark' ? { mode: 'benchmark' } : {}),
|
||||||
|
},
|
||||||
|
}).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const x = (series || []).map(p => p.scraped_at)
|
||||||
|
const priceTrace = {
|
||||||
|
x, y: (series || []).map(p => p.price_incl ?? null),
|
||||||
|
type: 'scatter' as const, mode: 'lines+markers' as const,
|
||||||
|
name: target.mode === 'benchmark' ? 'Benchmark rate' : target.mode === 'rate' ? 'Rate' : 'Cheapest rate',
|
||||||
|
line: { color: '#c9a84c', width: 2 },
|
||||||
|
marker: { size: 6 },
|
||||||
|
}
|
||||||
|
const traces: any[] = [priceTrace]
|
||||||
|
if (target.mode !== 'benchmark') {
|
||||||
|
traces.push({
|
||||||
|
x, y: (series || []).map(p => p.availability ?? null),
|
||||||
|
type: 'scatter' as const, mode: 'lines+markers' as const,
|
||||||
|
name: 'Availability', yaxis: 'y2',
|
||||||
|
line: { color: '#2563eb', width: 2, dash: 'dot' },
|
||||||
|
marker: { size: 5 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.55)', zIndex: 100,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ width: 'min(720px, 92vw)', maxHeight: '85vh', overflow: 'auto' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
{target.title}
|
||||||
|
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)' }} onClick={onClose}>
|
||||||
|
<X size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
{isLoading && <div className="loading-state"><div className="spinner" />Loading…</div>}
|
||||||
|
{!isLoading && (!series || series.length === 0) && (
|
||||||
|
<div className="empty-state">No history recorded yet for this date.</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && series && series.length > 0 && (
|
||||||
|
<Plot
|
||||||
|
data={traces}
|
||||||
|
layout={{
|
||||||
|
...PLOT_LAYOUT_BASE,
|
||||||
|
height: 320,
|
||||||
|
showlegend: target.mode !== 'benchmark',
|
||||||
|
legend: { orientation: 'h' as const, y: -0.25 },
|
||||||
|
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, title: { text: '£ per night' } },
|
||||||
|
yaxis2: {
|
||||||
|
overlaying: 'y' as const, side: 'right' as const,
|
||||||
|
gridcolor: 'transparent', zeroline: false,
|
||||||
|
title: { text: 'rooms available' }, rangemode: 'tozero' as const,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
config={{ displayModeBar: false, responsive: true }}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{target.mode === 'benchmark' && series?.some(p => p.calculated) && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 8 }}>
|
||||||
|
Some points are estimated from other room types via tier offsets.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Room Stats Tab ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STAT_WINDOWS = [
|
||||||
|
{ key: 'this_week', label: 'This Week (7 days)' },
|
||||||
|
{ key: 'this_month', label: 'This Month (30 days)' },
|
||||||
|
{ key: 'next_6mo', label: 'Next 6 Months' },
|
||||||
|
{ key: 'next_12mo', label: 'Next 12 Months' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function RoomStatsTab({ hotels, selectedHotel, onSelectHotel }: {
|
||||||
|
hotels: DirectHotel[]
|
||||||
|
selectedHotel: number | null
|
||||||
|
onSelectHotel: (id: number) => void
|
||||||
|
}) {
|
||||||
|
const { data: stats, isLoading } = useQuery<Record<string, RoomStats>>({
|
||||||
|
queryKey: ['direct-stats', selectedHotel],
|
||||||
|
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/stats`).then(r => r.data),
|
||||||
|
enabled: !!selectedHotel,
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomIds = stats
|
||||||
|
? Object.keys(stats).sort((a, b) => (stats[b].stock || 0) - (stats[a].stock || 0))
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
|
||||||
|
<HotelSelector hotels={hotels} selectedHotel={selectedHotel} onSelectHotel={onSelectHotel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-mid)', maxWidth: 720 }}>
|
||||||
|
Per-room breakdown on the benchmark rate. <strong>Stock</strong> is the highest availability ever
|
||||||
|
observed for the room. <strong>Current Occ</strong> compares the latest snapshot against stock across
|
||||||
|
upcoming dates. <strong>Est. Final Occ</strong> uses the equivalent look-back window — past dates no
|
||||||
|
longer change, so they reflect completed bookings.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedHotel && <div className="empty-state">Select a hotel to view room stats.</div>}
|
||||||
|
{selectedHotel && isLoading && <div className="loading-state"><div className="spinner" />Loading…</div>}
|
||||||
|
{selectedHotel && !isLoading && roomIds.length === 0 && (
|
||||||
|
<div className="empty-state">No data yet — run a scrape first.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedHotel && !isLoading && roomIds.length > 0 && STAT_WINDOWS.map(win => (
|
||||||
|
<div className="card" key={win.key}>
|
||||||
|
<div className="card-header">{win.label}</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Room Type</th>
|
||||||
|
<th>Stock</th>
|
||||||
|
<th>Avg Rate</th>
|
||||||
|
<th>Current Occ %</th>
|
||||||
|
<th>Est. Final Occ %</th>
|
||||||
|
<th>Dates</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{roomIds.map(rid => {
|
||||||
|
const room = stats![rid]
|
||||||
|
const w = room.windows[win.key]
|
||||||
|
if (!w) return (
|
||||||
|
<tr key={rid}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{room.room_label}</td>
|
||||||
|
<td style={{ color: 'var(--text-mid)' }}>{room.stock ?? '—'}</td>
|
||||||
|
<td colSpan={4} style={{ color: 'var(--text-mid)' }}>No data</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<tr key={rid}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{room.room_label}</td>
|
||||||
|
<td style={{ color: 'var(--text-mid)' }}>{room.stock ?? '—'}</td>
|
||||||
|
<td>{w.avg_price != null ? fmt(w.avg_price) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
{w.avg_occ != null
|
||||||
|
? <span><span style={{ color: occColor(w.avg_occ), fontWeight: 600 }}>{w.avg_occ}%</span><OccBar pct={w.avg_occ} /></span>
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{w.est_final_occ != null
|
||||||
|
? <span><span style={{ color: occColor(w.est_final_occ), fontWeight: 600 }}>{w.est_final_occ}%</span><OccBar pct={w.est_final_occ} /></span>
|
||||||
|
: <span style={{ color: 'var(--text-mid)', fontSize: 11 }}>no past dates</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ color: 'var(--text-mid)', fontSize: 12 }}>
|
||||||
|
{w.dates} ahead{w.past_dates ? ` · ${w.past_dates} past` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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 [detectUrl, setDetectUrl] = useState('')
|
const [detectUrl, setDetectUrl] = useState('')
|
||||||
const [detected, setDetected] = useState<any>(null)
|
const [detected, setDetected] = useState<any>(null)
|
||||||
const [detecting, setDetecting] = useState(false)
|
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [extraParams, setExtraParams] = useState<Record<string, string>>({})
|
const [extraParams, setExtraParams] = useState<Record<string, string>>({})
|
||||||
const [profiles, setProfiles] = useState<any[]>([])
|
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: profileList } = useQuery({
|
const { data: profileList } = useQuery({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue