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_rows = rates_result.fetchall()
|
||||
|
||||
# Fetch occupancy data from newbook_occupancy_report_data
|
||||
# Fetch occupancy data — latest snapshot per category/date
|
||||
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
|
||||
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_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_rows = occupancy_result.fetchall()
|
||||
|
||||
|
|
@ -728,6 +731,56 @@ def _refresh_date_sync(rate_date: date):
|
|||
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}")
|
||||
async def refresh_single_date(
|
||||
rate_date: str,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
"""
|
||||
Occupancy report sync — populates newbook_occupancy_report_data so the
|
||||
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
|
||||
from datetime import date, timedelta
|
||||
|
|
@ -10,9 +15,76 @@ from sqlalchemy import text
|
|||
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):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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}")
|
||||
vat = float(client.vat_rate or 0)
|
||||
records = 0
|
||||
inserted = 0
|
||||
verified = 0
|
||||
|
||||
for category in report:
|
||||
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():
|
||||
try:
|
||||
report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str
|
||||
available = int(day.get("available", 0) or 0)
|
||||
occupied = int(day.get("occupied", 0) or 0)
|
||||
maintenance = int(day.get("maintenance", 0) or 0)
|
||||
allotted = int(day.get("allotted", 0) or 0)
|
||||
report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str
|
||||
available = int(day.get("available", 0) or 0)
|
||||
occupied = int(day.get("occupied", 0) or 0)
|
||||
maintenance = int(day.get("maintenance", 0) or 0)
|
||||
allotted = int(day.get("allotted", 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:
|
||||
revenue_net = revenue_gross / (1 + vat) if vat else revenue_gross
|
||||
occupancy_pct = (occupied / available * 100) if available > 0 else 0
|
||||
|
||||
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, 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),
|
||||
}
|
||||
result = await _save_occupancy_snapshot(
|
||||
db, category_id, category_name, report_date,
|
||||
occupied, available, maintenance, allotted,
|
||||
revenue_gross, revenue_net, occupancy_pct
|
||||
)
|
||||
records += 1
|
||||
if result == 'inserted':
|
||||
inserted += 1
|
||||
else:
|
||||
verified += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping occupancy row {category_id}/{date_str}: {e}")
|
||||
await db.rollback()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Occupancy sync complete: {records} records upserted")
|
||||
return records
|
||||
logger.info(f"Occupancy sync complete: {inserted} new snapshots, {verified} unchanged")
|
||||
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);
|
||||
|
||||
-- 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
|
||||
-- ============================================
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Plot from 'react-plotly.js'
|
||||
import { X } from 'lucide-react'
|
||||
import api from '../api'
|
||||
|
||||
// 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 }>
|
||||
}
|
||||
|
||||
interface HistoryTarget {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
date: string
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC = () => (
|
||||
<div style={styles.loading}>
|
||||
<div style={styles.spinner} />
|
||||
|
|
@ -231,6 +239,7 @@ const Bookability: React.FC = () => {
|
|||
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
|
||||
})
|
||||
const [refreshingDate, setRefreshingDate] = useState<string | null>(null)
|
||||
const [history, setHistory] = useState<HistoryTarget | null>(null)
|
||||
|
||||
// Calculate date range from selected month
|
||||
const { fromDate, toDate } = useMemo(() => {
|
||||
|
|
@ -481,8 +490,11 @@ const Bookability: React.FC = () => {
|
|||
style={mergeStyles(
|
||||
styles.td,
|
||||
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>
|
||||
|
|
@ -508,9 +520,11 @@ const Bookability: React.FC = () => {
|
|||
styles.td,
|
||||
styles.occupancyCell,
|
||||
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}>
|
||||
{occ.occupied}/{occ.available}
|
||||
|
|
@ -553,7 +567,8 @@ const Bookability: React.FC = () => {
|
|||
isWeekend(dateStr) ? styles.weekendCell : {},
|
||||
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>
|
||||
|
|
@ -591,7 +606,8 @@ const Bookability: React.FC = () => {
|
|||
getCellStyle(),
|
||||
!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}>
|
||||
{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.cellNoRooms)}>No Rooms</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>
|
||||
|
||||
{history && <OccHistoryModal target={history} onClose={() => setHistory(null)} />}
|
||||
</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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue