Add occupancy pick-up history + bookability history popup
Schema: migrate newbook_occupancy_report_data from single-row upsert to
snapshot model (drop unique constraint, add valid_from / last_verified_at)
matching the pattern used by newbook_current_rates.
Backend: sync_occupancy now inserts a new row only when occupied/available/
maintenance figures change, otherwise bumps last_verified_at. New endpoint
GET /bookability/occupancy-history/{category_id}/{date} returns the timeline.
Rate matrix query updated to DISTINCT ON for the multi-row table.
Frontend: clicking any cell in the Bookability matrix opens a modal with
two stacked Plotly charts — rate history per tariff (step lines, green/red
markers for available/unavailable) and occupancy pick-up over time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aef7d755f1
commit
2743b0e877
4 changed files with 372 additions and 52 deletions
|
|
@ -259,9 +259,10 @@ async def get_rate_matrix(
|
||||||
rates_result = await db.execute(text(rates_query), rates_params)
|
rates_result = await db.execute(text(rates_query), rates_params)
|
||||||
rates_rows = rates_result.fetchall()
|
rates_rows = rates_result.fetchall()
|
||||||
|
|
||||||
# Fetch occupancy data from newbook_occupancy_report_data
|
# Fetch occupancy data — latest snapshot per category/date
|
||||||
occupancy_query = """
|
occupancy_query = """
|
||||||
SELECT category_id, date, occupied, available, maintenance
|
SELECT DISTINCT ON (category_id, date)
|
||||||
|
category_id, date, occupied, available, maintenance
|
||||||
FROM newbook_occupancy_report_data
|
FROM newbook_occupancy_report_data
|
||||||
WHERE date >= :from_date AND date <= :to_date
|
WHERE date >= :from_date AND date <= :to_date
|
||||||
"""
|
"""
|
||||||
|
|
@ -271,6 +272,8 @@ async def get_rate_matrix(
|
||||||
occupancy_query += " AND category_id = :category_id"
|
occupancy_query += " AND category_id = :category_id"
|
||||||
occupancy_params["category_id"] = category_id
|
occupancy_params["category_id"] = category_id
|
||||||
|
|
||||||
|
occupancy_query += " ORDER BY category_id, date, valid_from DESC"
|
||||||
|
|
||||||
occupancy_result = await db.execute(text(occupancy_query), occupancy_params)
|
occupancy_result = await db.execute(text(occupancy_query), occupancy_params)
|
||||||
occupancy_rows = occupancy_result.fetchall()
|
occupancy_rows = occupancy_result.fetchall()
|
||||||
|
|
||||||
|
|
@ -728,6 +731,56 @@ def _refresh_date_sync(rate_date: date):
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# OCCUPANCY HISTORY ENDPOINT
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@router.get("/occupancy-history/{category_id}/{rate_date}")
|
||||||
|
async def get_occupancy_history(
|
||||||
|
category_id: str,
|
||||||
|
rate_date: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get occupancy snapshot history for a specific category and stay date.
|
||||||
|
|
||||||
|
Returns all snapshots where occupied/available/maintenance changed,
|
||||||
|
ordered oldest-first so the frontend can build a pick-up timeline.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
target_date = date.fromisoformat(rate_date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT valid_from, last_verified_at, occupied, available, maintenance, occupancy_pct
|
||||||
|
FROM newbook_occupancy_report_data
|
||||||
|
WHERE date = :date AND category_id = :category_id
|
||||||
|
ORDER BY valid_from ASC
|
||||||
|
"""),
|
||||||
|
{"date": target_date, "category_id": category_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = result.fetchall()
|
||||||
|
return {
|
||||||
|
"category_id": category_id,
|
||||||
|
"rate_date": rate_date,
|
||||||
|
"snapshots": [
|
||||||
|
{
|
||||||
|
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||||
|
"last_verified_at": row.last_verified_at.isoformat() if row.last_verified_at else None,
|
||||||
|
"occupied": row.occupied,
|
||||||
|
"available": row.available,
|
||||||
|
"maintenance": row.maintenance,
|
||||||
|
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct is not None else None,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh-date/{rate_date}")
|
@router.post("/refresh-date/{rate_date}")
|
||||||
async def refresh_single_date(
|
async def refresh_single_date(
|
||||||
rate_date: str,
|
rate_date: str,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
"""
|
"""
|
||||||
Occupancy report sync — populates newbook_occupancy_report_data so the
|
Occupancy report sync — populates newbook_occupancy_report_data so the
|
||||||
Bookability matrix can show availability alongside rates.
|
Bookability matrix can show availability alongside rates.
|
||||||
|
|
||||||
|
Uses snapshot model (mirrors newbook_current_rates): only inserts a new row
|
||||||
|
when occupied/available/maintenance figures change; otherwise bumps
|
||||||
|
last_verified_at on the existing row. This lets the history popup show
|
||||||
|
how pick-up evolved over time for a given stay date.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
@ -10,9 +15,76 @@ from sqlalchemy import text
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_occupancy_snapshot(db, category_id, category_name, report_date,
|
||||||
|
occupied, available, maintenance, allotted,
|
||||||
|
revenue_gross, revenue_net, occupancy_pct):
|
||||||
|
"""
|
||||||
|
Insert a new occupancy snapshot only when figures have changed;
|
||||||
|
otherwise update last_verified_at on the latest row.
|
||||||
|
Returns 'inserted' or 'verified'.
|
||||||
|
"""
|
||||||
|
existing = (await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id, occupied, available, maintenance
|
||||||
|
FROM newbook_occupancy_report_data
|
||||||
|
WHERE date = :date AND category_id = :category_id
|
||||||
|
ORDER BY valid_from DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"date": report_date, "category_id": category_id}
|
||||||
|
)).fetchone()
|
||||||
|
|
||||||
|
changed = (
|
||||||
|
existing is None
|
||||||
|
or int(existing.occupied or 0) != occupied
|
||||||
|
or int(existing.available or 0) != available
|
||||||
|
or int(existing.maintenance or 0) != maintenance
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO newbook_occupancy_report_data (
|
||||||
|
date, category_id, category_name,
|
||||||
|
available, occupied, maintenance, allotted,
|
||||||
|
revenue_gross, revenue_net, occupancy_pct,
|
||||||
|
valid_from, last_verified_at, fetched_at
|
||||||
|
) VALUES (
|
||||||
|
:date, :category_id, :category_name,
|
||||||
|
:available, :occupied, :maintenance, :allotted,
|
||||||
|
:revenue_gross, :revenue_net, :occupancy_pct,
|
||||||
|
NOW(), NOW(), NOW()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"date": report_date,
|
||||||
|
"category_id": category_id,
|
||||||
|
"category_name": category_name,
|
||||||
|
"available": available,
|
||||||
|
"occupied": occupied,
|
||||||
|
"maintenance": maintenance,
|
||||||
|
"allotted": allotted,
|
||||||
|
"revenue_gross": round(revenue_gross, 2),
|
||||||
|
"revenue_net": round(revenue_net, 2),
|
||||||
|
"occupancy_pct": round(occupancy_pct, 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 'inserted'
|
||||||
|
else:
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE newbook_occupancy_report_data
|
||||||
|
SET last_verified_at = NOW(), fetched_at = NOW()
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": existing.id}
|
||||||
|
)
|
||||||
|
return 'verified'
|
||||||
|
|
||||||
|
|
||||||
async def run_sync_occupancy(days_ahead: int = 365):
|
async def run_sync_occupancy(days_ahead: int = 365):
|
||||||
"""
|
"""
|
||||||
Fetch the Newbook occupancy report from today forward and upsert
|
Fetch the Newbook occupancy report from today forward and snapshot
|
||||||
per-category, per-date availability into newbook_occupancy_report_data.
|
per-category, per-date availability into newbook_occupancy_report_data.
|
||||||
"""
|
"""
|
||||||
from database import AsyncSessionLocal
|
from database import AsyncSessionLocal
|
||||||
|
|
@ -28,7 +100,8 @@ async def run_sync_occupancy(days_ahead: int = 365):
|
||||||
|
|
||||||
logger.info(f"Occupancy report: {len(report)} categories, {from_date} to {to_date}")
|
logger.info(f"Occupancy report: {len(report)} categories, {from_date} to {to_date}")
|
||||||
vat = float(client.vat_rate or 0)
|
vat = float(client.vat_rate or 0)
|
||||||
records = 0
|
inserted = 0
|
||||||
|
verified = 0
|
||||||
|
|
||||||
for category in report:
|
for category in report:
|
||||||
category_id = str(category.get("category_id") or "")
|
category_id = str(category.get("category_id") or "")
|
||||||
|
|
@ -38,57 +111,31 @@ async def run_sync_occupancy(days_ahead: int = 365):
|
||||||
|
|
||||||
for date_str, day in (category.get("occupancy") or {}).items():
|
for date_str, day in (category.get("occupancy") or {}).items():
|
||||||
try:
|
try:
|
||||||
report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str
|
report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str
|
||||||
available = int(day.get("available", 0) or 0)
|
available = int(day.get("available", 0) or 0)
|
||||||
occupied = int(day.get("occupied", 0) or 0)
|
occupied = int(day.get("occupied", 0) or 0)
|
||||||
maintenance = int(day.get("maintenance", 0) or 0)
|
maintenance = int(day.get("maintenance", 0) or 0)
|
||||||
allotted = int(day.get("allotted", 0) or 0)
|
allotted = int(day.get("allotted", 0) or 0)
|
||||||
revenue_gross = float(day.get("revenue_gross", 0) or 0)
|
revenue_gross = float(day.get("revenue_gross", 0) or 0)
|
||||||
revenue_net = day.get("revenue_net")
|
revenue_net = day.get("revenue_net")
|
||||||
if revenue_net is None:
|
if revenue_net is None:
|
||||||
revenue_net = revenue_gross / (1 + vat) if vat else revenue_gross
|
revenue_net = revenue_gross / (1 + vat) if vat else revenue_gross
|
||||||
occupancy_pct = (occupied / available * 100) if available > 0 else 0
|
occupancy_pct = (occupied / available * 100) if available > 0 else 0
|
||||||
|
|
||||||
await db.execute(
|
result = await _save_occupancy_snapshot(
|
||||||
text("""
|
db, category_id, category_name, report_date,
|
||||||
INSERT INTO newbook_occupancy_report_data (
|
occupied, available, maintenance, allotted,
|
||||||
date, category_id, category_name,
|
revenue_gross, revenue_net, occupancy_pct
|
||||||
available, occupied, maintenance, allotted,
|
|
||||||
revenue_gross, revenue_net, occupancy_pct, fetched_at
|
|
||||||
) VALUES (
|
|
||||||
:date, :category_id, :category_name,
|
|
||||||
:available, :occupied, :maintenance, :allotted,
|
|
||||||
:revenue_gross, :revenue_net, :occupancy_pct, NOW()
|
|
||||||
)
|
|
||||||
ON CONFLICT (date, category_id) DO UPDATE SET
|
|
||||||
category_name = EXCLUDED.category_name,
|
|
||||||
available = EXCLUDED.available,
|
|
||||||
occupied = EXCLUDED.occupied,
|
|
||||||
maintenance = EXCLUDED.maintenance,
|
|
||||||
allotted = EXCLUDED.allotted,
|
|
||||||
revenue_gross = EXCLUDED.revenue_gross,
|
|
||||||
revenue_net = EXCLUDED.revenue_net,
|
|
||||||
occupancy_pct = EXCLUDED.occupancy_pct,
|
|
||||||
fetched_at = NOW()
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"date": report_date,
|
|
||||||
"category_id": category_id,
|
|
||||||
"category_name": category_name,
|
|
||||||
"available": available,
|
|
||||||
"occupied": occupied,
|
|
||||||
"maintenance": maintenance,
|
|
||||||
"allotted": allotted,
|
|
||||||
"revenue_gross": round(revenue_gross, 2),
|
|
||||||
"revenue_net": round(revenue_net, 2),
|
|
||||||
"occupancy_pct": round(occupancy_pct, 2),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
records += 1
|
if result == 'inserted':
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
verified += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Skipping occupancy row {category_id}/{date_str}: {e}")
|
logger.warning(f"Skipping occupancy row {category_id}/{date_str}: {e}")
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info(f"Occupancy sync complete: {records} records upserted")
|
logger.info(f"Occupancy sync complete: {inserted} new snapshots, {verified} unchanged")
|
||||||
return records
|
return inserted + verified
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,42 @@ CREATE TABLE IF NOT EXISTS newbook_occupancy_report_data (
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_occupancy_report_date ON newbook_occupancy_report_data(date);
|
CREATE INDEX IF NOT EXISTS idx_occupancy_report_date ON newbook_occupancy_report_data(date);
|
||||||
|
|
||||||
|
-- Migrate occupancy table to snapshot model (drop unique constraint, add valid_from / last_verified_at)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
WHERE t.relname = 'newbook_occupancy_report_data'
|
||||||
|
AND c.contype = 'u'
|
||||||
|
AND c.conname = 'newbook_occupancy_report_data_date_category_id_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE newbook_occupancy_report_data
|
||||||
|
DROP CONSTRAINT newbook_occupancy_report_data_date_category_id_key;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'valid_from'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE newbook_occupancy_report_data ADD COLUMN valid_from TIMESTAMPTZ;
|
||||||
|
UPDATE newbook_occupancy_report_data SET valid_from = fetched_at WHERE valid_from IS NULL;
|
||||||
|
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN valid_from SET DEFAULT NOW();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'last_verified_at'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE newbook_occupancy_report_data ADD COLUMN last_verified_at TIMESTAMPTZ;
|
||||||
|
UPDATE newbook_occupancy_report_data SET last_verified_at = fetched_at WHERE last_verified_at IS NULL;
|
||||||
|
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN last_verified_at SET DEFAULT NOW();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_occupancy_report_latest
|
||||||
|
ON newbook_occupancy_report_data(date, category_id, valid_from DESC);
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- DIRECT COMPETITOR HOTEL CONFIGS
|
-- DIRECT COMPETITOR HOTEL CONFIGS
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import Plot from 'react-plotly.js'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString)
|
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString)
|
||||||
|
|
@ -209,6 +211,12 @@ interface BookingAvailabilityData {
|
||||||
dates: Record<string, { status: string; rate: number | null }>
|
dates: Record<string, { status: string; rate: number | null }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HistoryTarget {
|
||||||
|
categoryId: string
|
||||||
|
categoryName: string
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
const LoadingSpinner: React.FC = () => (
|
const LoadingSpinner: React.FC = () => (
|
||||||
<div style={styles.loading}>
|
<div style={styles.loading}>
|
||||||
<div style={styles.spinner} />
|
<div style={styles.spinner} />
|
||||||
|
|
@ -231,6 +239,7 @@ const Bookability: React.FC = () => {
|
||||||
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
|
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
|
||||||
})
|
})
|
||||||
const [refreshingDate, setRefreshingDate] = useState<string | null>(null)
|
const [refreshingDate, setRefreshingDate] = useState<string | null>(null)
|
||||||
|
const [history, setHistory] = useState<HistoryTarget | null>(null)
|
||||||
|
|
||||||
// Calculate date range from selected month
|
// Calculate date range from selected month
|
||||||
const { fromDate, toDate } = useMemo(() => {
|
const { fromDate, toDate } = useMemo(() => {
|
||||||
|
|
@ -481,8 +490,11 @@ const Bookability: React.FC = () => {
|
||||||
style={mergeStyles(
|
style={mergeStyles(
|
||||||
styles.td,
|
styles.td,
|
||||||
styles.occupancyCell,
|
styles.occupancyCell,
|
||||||
isWeekend(dateStr) ? styles.weekendCell : {}
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
||||||
|
{ cursor: 'pointer' }
|
||||||
)}
|
)}
|
||||||
|
title="Click for history"
|
||||||
|
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -508,9 +520,11 @@ const Bookability: React.FC = () => {
|
||||||
styles.td,
|
styles.td,
|
||||||
styles.occupancyCell,
|
styles.occupancyCell,
|
||||||
isWeekend(dateStr) ? styles.weekendCell : {},
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
||||||
getOccStyle()
|
getOccStyle(),
|
||||||
|
{ cursor: 'pointer' }
|
||||||
)}
|
)}
|
||||||
title={`${occ.occupied} of ${bookableRooms} bookable rooms occupied${hasOffline ? ` (${occ.maintenance} offline)` : ''} - ${roomsLeft} left`}
|
title={`${occ.occupied} of ${bookableRooms} bookable rooms occupied${hasOffline ? ` (${occ.maintenance} offline)` : ''} - ${roomsLeft} left · click for history`}
|
||||||
|
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
|
||||||
>
|
>
|
||||||
<span style={styles.occupancyText}>
|
<span style={styles.occupancyText}>
|
||||||
{occ.occupied}/{occ.available}
|
{occ.occupied}/{occ.available}
|
||||||
|
|
@ -553,7 +567,8 @@ const Bookability: React.FC = () => {
|
||||||
isWeekend(dateStr) ? styles.weekendCell : {},
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
||||||
noRoomsAvailable ? styles.cellNoRooms : styles.cellNoData
|
noRoomsAvailable ? styles.cellNoRooms : styles.cellNoData
|
||||||
)}
|
)}
|
||||||
title={noRoomsAvailable ? 'No rooms available' : undefined}
|
title={noRoomsAvailable ? 'No rooms available · click for history' : 'Click for history'}
|
||||||
|
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -591,7 +606,8 @@ const Bookability: React.FC = () => {
|
||||||
getCellStyle(),
|
getCellStyle(),
|
||||||
!isEffectivelyAvailable && !noRoomsAvailable ? { textDecoration: 'line-through' } : {}
|
!isEffectivelyAvailable && !noRoomsAvailable ? { textDecoration: 'line-through' } : {}
|
||||||
)}
|
)}
|
||||||
title={getTooltip()}
|
title={`${getTooltip()} · click for history`}
|
||||||
|
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
|
||||||
>
|
>
|
||||||
<span style={styles.cellContent}>
|
<span style={styles.cellContent}>
|
||||||
{tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')}
|
{tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')}
|
||||||
|
|
@ -704,7 +720,10 @@ const Bookability: React.FC = () => {
|
||||||
<span style={mergeStyles(styles.legendItem, styles.cellUnavailable)}>Unavailable</span>
|
<span style={mergeStyles(styles.legendItem, styles.cellUnavailable)}>Unavailable</span>
|
||||||
<span style={mergeStyles(styles.legendItem, styles.cellNoRooms)}>No Rooms</span>
|
<span style={mergeStyles(styles.legendItem, styles.cellNoRooms)}>No Rooms</span>
|
||||||
<span style={mergeStyles(styles.legendItem, styles.cellNoData)}>No Data</span>
|
<span style={mergeStyles(styles.legendItem, styles.cellNoData)}>No Data</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-mid)' }}>Click any cell for rate & occupancy history</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{history && <OccHistoryModal target={history} onClose={() => setHistory(null)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1100,4 +1119,169 @@ const styles: Record<string, React.CSSProperties> = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Occupancy + Rate History Modal ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RateHistorySnapshot {
|
||||||
|
valid_from: string
|
||||||
|
tariffs: { name: string; rate: number | null; available: boolean; min_stay?: number | null }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OccSnapshot {
|
||||||
|
valid_from: string
|
||||||
|
occupied: number
|
||||||
|
available: number
|
||||||
|
occupancy_pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLOT_BASE = {
|
||||||
|
paper_bgcolor: 'transparent',
|
||||||
|
plot_bgcolor: 'transparent',
|
||||||
|
font: { family: 'Inter, system-ui, sans-serif', size: 12, color: '#60748b' },
|
||||||
|
margin: { t: 10, r: 24, b: 48, l: 52 },
|
||||||
|
xaxis: { gridcolor: '#e5e9f0', zeroline: false },
|
||||||
|
yaxis: { gridcolor: '#e5e9f0', zeroline: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACE_COLORS = ['#2563eb', '#d97706', '#7c3aed', '#059669', '#0891b2', '#be185d']
|
||||||
|
|
||||||
|
function OccHistoryModal({ target, onClose }: { target: HistoryTarget; onClose: () => void }) {
|
||||||
|
const { data: rateData, isLoading: rateLoading } = useQuery<{
|
||||||
|
history: RateHistorySnapshot[]
|
||||||
|
version_count: number
|
||||||
|
}>({
|
||||||
|
queryKey: ['bookability-rate-history', target.categoryId, target.date],
|
||||||
|
queryFn: () => api.get(`/bookability/rate-history/${target.categoryId}/${target.date}`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: occData, isLoading: occLoading } = useQuery<{ snapshots: OccSnapshot[] }>({
|
||||||
|
queryKey: ['bookability-occ-history', target.categoryId, target.date],
|
||||||
|
queryFn: () => api.get(`/bookability/occupancy-history/${target.categoryId}/${target.date}`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rateTraces = useMemo(() => {
|
||||||
|
const history = rateData?.history ?? []
|
||||||
|
if (!history.length) return []
|
||||||
|
const tariffNames = [...new Set(history.flatMap(h => h.tariffs.map(t => t.name)))]
|
||||||
|
return tariffNames.map((name, i) => {
|
||||||
|
const pts = history
|
||||||
|
.map(h => ({ x: h.valid_from, t: h.tariffs.find(t => t.name === name) }))
|
||||||
|
.filter(p => p.t?.rate != null)
|
||||||
|
return {
|
||||||
|
x: pts.map(p => p.x),
|
||||||
|
y: pts.map(p => p.t!.rate),
|
||||||
|
type: 'scatter' as const,
|
||||||
|
mode: 'lines+markers' as const,
|
||||||
|
name,
|
||||||
|
line: { color: TRACE_COLORS[i % TRACE_COLORS.length], shape: 'hv' as const, width: 2 },
|
||||||
|
marker: {
|
||||||
|
size: 8,
|
||||||
|
color: pts.map(p => p.t!.available ? '#16a34a' : '#dc2626'),
|
||||||
|
symbol: pts.map(p => (p.t!.available ? 'circle' : 'x') as any),
|
||||||
|
line: { color: TRACE_COLORS[i % TRACE_COLORS.length], width: 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [rateData])
|
||||||
|
|
||||||
|
const occTrace = useMemo(() => {
|
||||||
|
const snaps = occData?.snapshots ?? []
|
||||||
|
if (!snaps.length) return null
|
||||||
|
return {
|
||||||
|
x: snaps.map(s => s.valid_from),
|
||||||
|
y: snaps.map(s => s.occupancy_pct),
|
||||||
|
type: 'scatter' as const,
|
||||||
|
mode: 'lines+markers' as const,
|
||||||
|
name: 'Occupancy %',
|
||||||
|
fill: 'tozeroy' as const,
|
||||||
|
fillcolor: 'rgba(201,168,76,0.12)',
|
||||||
|
line: { color: 'var(--gold, #c9a84c)', shape: 'hv' as const, width: 2 },
|
||||||
|
marker: { size: 5, color: '#c9a84c' },
|
||||||
|
}
|
||||||
|
}, [occData])
|
||||||
|
|
||||||
|
const isLoading = rateLoading || occLoading
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.55)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ width: 'min(780px, 92vw)', maxHeight: '88vh', overflow: 'auto' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{target.categoryName} — {target.date}</span>
|
||||||
|
<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, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
{isLoading && <div className="loading-state"><div className="spinner" />Loading history…</div>}
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<>
|
||||||
|
{/* Rate history */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8 }}>
|
||||||
|
Rate History
|
||||||
|
{rateData?.version_count ? <span style={{ fontWeight: 400, marginLeft: 8 }}>({rateData.version_count} change{rateData.version_count !== 1 ? 's' : ''})</span> : null}
|
||||||
|
</div>
|
||||||
|
{!rateTraces.length ? (
|
||||||
|
<div style={{ padding: '20px 0', color: 'var(--text-mid)', fontSize: 13, textAlign: 'center' }}>No rate history recorded yet.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plot
|
||||||
|
data={rateTraces}
|
||||||
|
layout={{
|
||||||
|
...PLOT_BASE,
|
||||||
|
height: 240,
|
||||||
|
showlegend: true,
|
||||||
|
legend: { orientation: 'h' as const, y: -0.35, font: { size: 11 } },
|
||||||
|
yaxis: { ...PLOT_BASE.yaxis, tickprefix: '£', title: { text: '£ / night' } },
|
||||||
|
}}
|
||||||
|
config={{ displayModeBar: false, responsive: true }}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 2 }}>
|
||||||
|
Green circle = available · Red ✕ = unavailable at that snapshot
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Occupancy history */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8 }}>
|
||||||
|
Occupancy Pick-up
|
||||||
|
{occData?.snapshots.length ? <span style={{ fontWeight: 400, marginLeft: 8 }}>({occData.snapshots.length} snapshot{occData.snapshots.length !== 1 ? 's' : ''})</span> : null}
|
||||||
|
</div>
|
||||||
|
{!occTrace ? (
|
||||||
|
<div style={{ padding: '20px 0', color: 'var(--text-mid)', fontSize: 13, textAlign: 'center' }}>
|
||||||
|
No occupancy snapshots yet — pick-up history starts accumulating from first sync after this deployment.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Plot
|
||||||
|
data={[occTrace]}
|
||||||
|
layout={{
|
||||||
|
...PLOT_BASE,
|
||||||
|
height: 180,
|
||||||
|
showlegend: false,
|
||||||
|
yaxis: { ...PLOT_BASE.yaxis, range: [0, 100], ticksuffix: '%', title: { text: 'Occ %' } },
|
||||||
|
}}
|
||||||
|
config={{ displayModeBar: false, responsive: true }}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Bookability
|
export default Bookability
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue