Apply 45% opacity to all column cells where stay_date < today — date header, category fill, occupancy, and all tariff cells — matching the same visual treatment as the Direct Rates table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1295 lines
46 KiB
TypeScript
1295 lines
46 KiB
TypeScript
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)
|
|
const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
|
|
// Types
|
|
interface CategoryInfo {
|
|
category_id: string
|
|
category_name: string
|
|
room_count: number
|
|
}
|
|
|
|
interface TariffInfo {
|
|
name: string
|
|
description?: string
|
|
rate: number | null
|
|
average_nightly?: number
|
|
available: boolean
|
|
message: string
|
|
sort_order?: number
|
|
min_stay?: number | null
|
|
available_for_min_stay?: boolean | null // True if available when queried with min_stay nights
|
|
}
|
|
|
|
interface OccupancyInfo {
|
|
occupied: number
|
|
available: number
|
|
maintenance: number
|
|
}
|
|
|
|
interface DateRateInfo {
|
|
rate_gross: number | null
|
|
rate_net: number | null
|
|
tariffs: TariffInfo[]
|
|
tariff_count: number
|
|
occupancy?: OccupancyInfo
|
|
valid_from?: string | null
|
|
}
|
|
|
|
interface RateMatrixData {
|
|
categories: CategoryInfo[]
|
|
dates: string[]
|
|
matrix: Record<string, Record<string, DateRateInfo>>
|
|
date_last_updated?: Record<string, string | null>
|
|
date_last_changed?: Record<string, string | null>
|
|
}
|
|
|
|
// Helper functions
|
|
const formatDateShort = (dateStr: string): string => {
|
|
const date = new Date(dateStr + 'T00:00:00')
|
|
return date.toLocaleDateString('en-GB', { day: 'numeric' })
|
|
}
|
|
|
|
const formatDayOfWeek = (dateStr: string): string => {
|
|
const date = new Date(dateStr + 'T00:00:00')
|
|
return date.toLocaleDateString('en-GB', { weekday: 'short' })
|
|
}
|
|
|
|
const formatLastUpdated = (isoStr: string | null | undefined): string => {
|
|
if (!isoStr) return ''
|
|
const d = new Date(isoStr)
|
|
const now = new Date()
|
|
const isToday = d.toDateString() === now.toDateString()
|
|
return isToday
|
|
? d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
|
: d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
const isWeekend = (dateStr: string): boolean => {
|
|
const date = new Date(dateStr + 'T00:00:00')
|
|
const day = date.getDay()
|
|
return day === 0 || day === 6
|
|
}
|
|
|
|
const todayStr = fmtDate(new Date())
|
|
const isPast = (dateStr: string): boolean => dateStr < todayStr
|
|
|
|
const formatCurrency = (value: number | null): string => {
|
|
if (value === null || value === undefined) return '-'
|
|
return new Intl.NumberFormat('en-GB', {
|
|
style: 'currency',
|
|
currency: 'GBP',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(value)
|
|
}
|
|
|
|
// Inline style helpers (replacing theme utilities)
|
|
const mergeStyles = (...styles: React.CSSProperties[]): React.CSSProperties =>
|
|
Object.assign({}, ...styles)
|
|
|
|
const buttonStyle = (variant: 'primary' | 'secondary' | 'outline', size?: 'small'): React.CSSProperties => {
|
|
const base: React.CSSProperties = {
|
|
border: 'none',
|
|
borderRadius: '6px',
|
|
cursor: 'pointer',
|
|
fontWeight: 500,
|
|
padding: size === 'small' ? '4px 10px' : '8px 16px',
|
|
fontSize: size === 'small' ? '13px' : '14px',
|
|
lineHeight: 1.4,
|
|
transition: 'all 0.15s',
|
|
}
|
|
if (variant === 'primary') return { ...base, background: 'var(--gold)', color: '#fff' }
|
|
if (variant === 'secondary') return { ...base, background: 'var(--navy)', color: '#fff' }
|
|
// outline
|
|
return { ...base, background: 'transparent', color: 'var(--text-dark)', border: '1px solid var(--card-border)' }
|
|
}
|
|
|
|
const badgeStyle = (variant: 'success' | 'error' | 'warning' | 'info'): React.CSSProperties => {
|
|
const map: Record<string, React.CSSProperties> = {
|
|
success: { background: '#dcfce7', color: '#16a34a', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
|
|
error: { background: '#fee2e2', color: '#dc2626', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
|
|
warning: { background: '#fef3c7', color: '#d97706', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
|
|
info: { background: '#dbeafe', color: '#2563eb', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
|
|
}
|
|
return map[variant] || map.info
|
|
}
|
|
|
|
// Components
|
|
const MonthSelector: React.FC<{
|
|
value: string
|
|
onChange: (value: string) => void
|
|
}> = ({ value, onChange }) => {
|
|
const handlePrevMonth = () => {
|
|
const [year, month] = value.split('-').map(Number)
|
|
const date = new Date(year, month - 2, 1)
|
|
onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`)
|
|
}
|
|
|
|
const handleNextMonth = () => {
|
|
const [year, month] = value.split('-').map(Number)
|
|
const date = new Date(year, month, 1)
|
|
onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`)
|
|
}
|
|
|
|
// Generate month options (current month + next 12 months)
|
|
const monthOptions = useMemo(() => {
|
|
const options: { value: string; label: string }[] = []
|
|
const now = new Date()
|
|
for (let i = 0; i < 13; i++) {
|
|
const date = new Date(now.getFullYear(), now.getMonth() + i, 1)
|
|
const monthValue = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
|
const label = date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
|
|
options.push({ value: monthValue, label })
|
|
}
|
|
return options
|
|
}, [])
|
|
|
|
return (
|
|
<div style={styles.monthSelector}>
|
|
<button onClick={handlePrevMonth} style={buttonStyle('outline', 'small')}>
|
|
←
|
|
</button>
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={styles.monthDropdown}
|
|
>
|
|
{monthOptions.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button onClick={handleNextMonth} style={buttonStyle('outline', 'small')}>
|
|
→
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Helper to collect unique tariff names across all dates for a category, preserving Newbook order
|
|
const getAllTariffNames = (rateData: Record<string, DateRateInfo>, dates: string[]): string[] => {
|
|
const tariffMap = new Map<string, number>()
|
|
for (const dateStr of dates) {
|
|
const data = rateData[dateStr]
|
|
if (data?.tariffs) {
|
|
for (const tariff of data.tariffs) {
|
|
if (!tariffMap.has(tariff.name)) {
|
|
tariffMap.set(tariff.name, tariff.sort_order ?? 999)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Array.from(tariffMap.entries())
|
|
.sort((a, b) => a[1] - b[1])
|
|
.map(([name]) => name)
|
|
}
|
|
|
|
// Helper to format scrape age
|
|
const formatScrapeAge = (isoStr: string | null): string => {
|
|
if (!isoStr) return ''
|
|
const diff = Date.now() - new Date(isoStr).getTime()
|
|
const mins = Math.floor(diff / 60000)
|
|
if (mins < 60) return `${mins}m ago`
|
|
const hours = Math.floor(mins / 60)
|
|
if (hours < 24) return `${hours}h ago`
|
|
const days = Math.floor(hours / 24)
|
|
return `${days}d ago`
|
|
}
|
|
|
|
interface BookingAvailabilityData {
|
|
has_own_hotel: boolean
|
|
dates_checked: number
|
|
dates_available: number
|
|
dates_sold_out: number
|
|
dates_no_data: number
|
|
latest_scrape: string | null
|
|
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} />
|
|
<span>Loading rate data...</span>
|
|
</div>
|
|
)
|
|
|
|
const ErrorMessage: React.FC<{ message: string }> = ({ message }) => (
|
|
<div style={styles.error}>
|
|
<span style={styles.errorIcon}>!</span>
|
|
{message}
|
|
</div>
|
|
)
|
|
|
|
// Main Component
|
|
const Bookability: React.FC = () => {
|
|
const queryClient = useQueryClient()
|
|
const [selectedMonth, setSelectedMonth] = useState(() => {
|
|
const today = new Date()
|
|
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(() => {
|
|
const [year, month] = selectedMonth.split('-').map(Number)
|
|
const start = new Date(year, month - 1, 1)
|
|
const end = new Date(year, month, 0) // Last day of month
|
|
return {
|
|
fromDate: fmtDate(start),
|
|
toDate: fmtDate(end),
|
|
}
|
|
}, [selectedMonth])
|
|
|
|
// Single-date refresh mutation
|
|
const dateRefreshM = useMutation({
|
|
mutationFn: async (d: string) => {
|
|
setRefreshingDate(d)
|
|
const res = await api.post(`/bookability/refresh-date/${d}`)
|
|
return res.data
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['rate-matrix'] })
|
|
setRefreshingDate(null)
|
|
},
|
|
onError: () => setRefreshingDate(null),
|
|
})
|
|
|
|
// Fetch Booking.com availability data
|
|
const { data: bookingData } = useQuery<BookingAvailabilityData>({
|
|
queryKey: ['booking-availability', fromDate, toDate],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams({ from_date: fromDate, to_date: toDate })
|
|
const res = await api.get(`/competitors/booking-availability?${params}`)
|
|
return res.data
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
})
|
|
|
|
// Fetch rate matrix data
|
|
const { data, isLoading, error, refetch } = useQuery<RateMatrixData>({
|
|
queryKey: ['rate-matrix', fromDate, toDate],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams({ from_date: fromDate, to_date: toDate })
|
|
const res = await api.get(`/bookability/rate-matrix?${params}`)
|
|
return res.data
|
|
},
|
|
})
|
|
|
|
// Calculate summary stats - focus on unbookable dates (rooms available but no rates)
|
|
const summary = useMemo(() => {
|
|
if (!data) return null
|
|
|
|
let totalDateCategories = 0
|
|
let unbookableDateCategories = 0
|
|
const unbookableIssues: { category: string; date: string; roomsLeft: number }[] = []
|
|
|
|
for (const cat of data.categories) {
|
|
const catData = data.matrix[cat.category_id]
|
|
if (!catData) continue
|
|
|
|
for (const dateStr of data.dates) {
|
|
const dayData = catData[dateStr]
|
|
if (!dayData) continue
|
|
|
|
// Check if rooms are available (bookable = available - maintenance - occupied)
|
|
const occ = dayData.occupancy
|
|
const bookableRooms = occ ? occ.available - occ.maintenance : 0
|
|
const roomsLeft = occ ? bookableRooms - occ.occupied : 0
|
|
const hasRoomsAvailable = roomsLeft > 0
|
|
|
|
// Only count dates where rooms are available
|
|
if (hasRoomsAvailable) {
|
|
totalDateCategories++
|
|
|
|
// Check if ANY tariff is available for booking
|
|
// A tariff is "bookable" if:
|
|
// - available: true (single-night available), OR
|
|
// - has min_stay > 1 AND available_for_min_stay: true (verified via multi-night query)
|
|
const hasAnyAvailableRate = dayData.tariffs?.some(t => {
|
|
if (t.available) return true
|
|
// If has min_stay requirement and verified available for that stay length
|
|
if (t.min_stay && t.min_stay > 1 && t.available_for_min_stay === true) return true
|
|
return false
|
|
}) ?? false
|
|
|
|
if (!hasAnyAvailableRate && dayData.tariffs && dayData.tariffs.length > 0) {
|
|
// Rooms available but no rates bookable - this is a problem!
|
|
unbookableDateCategories++
|
|
unbookableIssues.push({
|
|
category: cat.category_name,
|
|
date: dateStr,
|
|
roomsLeft: roomsLeft,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalDateCategories,
|
|
unbookableDateCategories,
|
|
unbookablePercent: totalDateCategories > 0
|
|
? ((unbookableDateCategories / totalDateCategories) * 100).toFixed(1)
|
|
: '0',
|
|
issues: unbookableIssues.slice(0, 10),
|
|
hasMoreIssues: unbookableIssues.length > 10,
|
|
totalIssues: unbookableIssues.length,
|
|
}
|
|
}, [data])
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
{/* Header */}
|
|
<div style={styles.header}>
|
|
<div style={styles.headerTop}>
|
|
<div>
|
|
<h1 style={styles.title}>Rate Availability</h1>
|
|
<p style={styles.subtitle}>
|
|
View tariff availability across all room categories
|
|
</p>
|
|
</div>
|
|
<div style={styles.headerActions}>
|
|
<MonthSelector value={selectedMonth} onChange={setSelectedMonth} />
|
|
<button
|
|
onClick={() => refetch()}
|
|
style={buttonStyle('outline', 'small')}
|
|
disabled={isLoading}
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
{summary && (
|
|
<div style={styles.summaryBar}>
|
|
<div style={styles.summaryItem}>
|
|
<span style={styles.summaryValue}>{data?.categories.length || 0}</span>
|
|
<span style={styles.summaryLabel}>Room Types</span>
|
|
</div>
|
|
<div style={styles.summaryItem}>
|
|
<span style={styles.summaryValue}>{data?.dates.length || 0}</span>
|
|
<span style={styles.summaryLabel}>Days</span>
|
|
</div>
|
|
<div style={styles.summaryItem}>
|
|
<span style={mergeStyles(
|
|
styles.summaryValue,
|
|
summary.unbookableDateCategories > 0 ? { color: 'var(--danger)' } : { color: 'var(--success)' }
|
|
)}>
|
|
{summary.unbookableDateCategories}
|
|
</span>
|
|
<span style={styles.summaryLabel}>Unbookable</span>
|
|
</div>
|
|
<div style={styles.summaryItem}>
|
|
<span style={badgeStyle(summary.unbookableDateCategories > 0 ? 'warning' : 'success')}>
|
|
{summary.unbookablePercent}% blocked
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div style={styles.content}>
|
|
{isLoading && <LoadingSpinner />}
|
|
{error && <ErrorMessage message={(error as Error).message} />}
|
|
{data && data.categories.length === 0 && (
|
|
<div style={styles.noData}>
|
|
No room categories configured. Please set up room categories in Settings.
|
|
</div>
|
|
)}
|
|
{data && data.categories.length > 0 && (
|
|
<div style={styles.unifiedCard}>
|
|
<div style={styles.tableContainer}>
|
|
<table style={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th style={mergeStyles(styles.th, styles.stickyCol, styles.stickyHeader)}>Tariff</th>
|
|
{data.dates.map(dateStr => (
|
|
<th
|
|
key={dateStr}
|
|
style={mergeStyles(
|
|
styles.th,
|
|
styles.dateHeader,
|
|
styles.stickyHeader,
|
|
isWeekend(dateStr) ? styles.weekendHeader : {},
|
|
isPast(dateStr) ? { opacity: 0.45 } : {}
|
|
)}
|
|
>
|
|
<div style={styles.dateHeaderContent}>
|
|
<span style={styles.dayOfWeek}>{formatDayOfWeek(dateStr)}</span>
|
|
<span style={styles.dayNum}>{formatDateShort(dateStr)}</span>
|
|
<button
|
|
onClick={() => dateRefreshM.mutate(dateStr)}
|
|
disabled={refreshingDate !== null}
|
|
style={mergeStyles(
|
|
styles.scrapeBtn,
|
|
refreshingDate === dateStr ? styles.scrapeBtnActive : {}
|
|
)}
|
|
title={`Refresh ${dateStr}`}
|
|
>
|
|
{refreshingDate === dateStr ? '...' : '↻'}
|
|
</button>
|
|
{data.date_last_updated?.[dateStr] && (
|
|
<span
|
|
style={styles.lastUpdated}
|
|
title={[
|
|
`Checked: ${new Date(data.date_last_updated[dateStr]!).toLocaleString('en-GB')}`,
|
|
data.date_last_changed?.[dateStr]
|
|
? `Changed: ${new Date(data.date_last_changed[dateStr]!).toLocaleString('en-GB')}`
|
|
: null,
|
|
].filter(Boolean).join('\n')}
|
|
>
|
|
{formatLastUpdated(data.date_last_updated[dateStr])}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.categories.map(category => {
|
|
const rateData = data.matrix[category.category_id] || {}
|
|
const tariffNames = getAllTariffNames(rateData, data.dates)
|
|
return (
|
|
<React.Fragment key={category.category_id}>
|
|
{/* Category header row */}
|
|
<tr>
|
|
<td style={mergeStyles(styles.categoryHeaderRow, styles.stickyCol, { zIndex: 15, overflow: 'visible', textOverflow: 'clip', width: 'auto', minWidth: '160px' })}>
|
|
{category.category_name}
|
|
<span style={styles.roomCount}> ({category.room_count} rooms)</span>
|
|
</td>
|
|
{data.dates.map(dateStr => (
|
|
<td key={dateStr} style={mergeStyles(styles.categoryHeaderFill, isPast(dateStr) ? { opacity: 0.45 } : {})} />
|
|
))}
|
|
</tr>
|
|
{/* Occupancy row */}
|
|
<tr>
|
|
<td style={mergeStyles(styles.td, styles.stickyCol, styles.occupancyLabel)}>
|
|
Occupancy
|
|
</td>
|
|
{data.dates.map(dateStr => {
|
|
const dayData = rateData[dateStr]
|
|
const occ = dayData?.occupancy
|
|
if (!occ) {
|
|
return (
|
|
<td
|
|
key={dateStr}
|
|
style={mergeStyles(
|
|
styles.td,
|
|
styles.occupancyCell,
|
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
|
{ cursor: 'pointer' },
|
|
isPast(dateStr) ? { opacity: 0.45 } : {}
|
|
)}
|
|
title="Click for history"
|
|
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
|
|
>
|
|
-
|
|
</td>
|
|
)
|
|
}
|
|
const bookableRooms = occ.available - occ.maintenance
|
|
const roomsLeft = bookableRooms - occ.occupied
|
|
const isFull = roomsLeft <= 0
|
|
const occPercent = bookableRooms > 0
|
|
? Math.round((occ.occupied / bookableRooms) * 100)
|
|
: 100
|
|
const isHighOcc = occPercent >= 80 && !isFull
|
|
const hasOffline = occ.maintenance > 0
|
|
const getOccStyle = () => {
|
|
if (isFull) return styles.occupancyFull
|
|
if (isHighOcc) return styles.occupancyHigh
|
|
return styles.occupancyAvailable
|
|
}
|
|
return (
|
|
<td
|
|
key={dateStr}
|
|
style={mergeStyles(
|
|
styles.td,
|
|
styles.occupancyCell,
|
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
|
getOccStyle(),
|
|
{ cursor: 'pointer' },
|
|
isPast(dateStr) ? { opacity: 0.45 } : {}
|
|
)}
|
|
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}
|
|
{hasOffline && <span style={styles.maintenanceBadge}>({occ.maintenance})</span>}
|
|
</span>
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
{/* Tariff rows */}
|
|
{tariffNames.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={data.dates.length + 1}
|
|
style={{ ...styles.td, color: 'var(--text-mid)', textAlign: 'center' }}
|
|
>
|
|
No rate data available for this period
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
tariffNames.map(tariffName => (
|
|
<tr key={tariffName}>
|
|
<td style={mergeStyles(styles.td, styles.stickyCol, styles.tariffNameCell)}>
|
|
{tariffName}
|
|
</td>
|
|
{data.dates.map(dateStr => {
|
|
const dayData = rateData[dateStr]
|
|
const tariff = dayData?.tariffs?.find(t => t.name === tariffName)
|
|
const occupancy = dayData?.occupancy
|
|
const noRoomsAvailable = occupancy &&
|
|
(occupancy.available - occupancy.maintenance - occupancy.occupied) <= 0
|
|
|
|
if (!tariff) {
|
|
return (
|
|
<td
|
|
key={dateStr}
|
|
style={mergeStyles(
|
|
styles.td,
|
|
styles.tariffCell,
|
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
|
noRoomsAvailable ? styles.cellNoRooms : styles.cellNoData,
|
|
isPast(dateStr) ? { opacity: 0.45 } : {}
|
|
)}
|
|
title={noRoomsAvailable ? 'No rooms available · click for history' : 'Click for history'}
|
|
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
|
|
>
|
|
-
|
|
</td>
|
|
)
|
|
}
|
|
|
|
const isEffectivelyAvailable = tariff.available ||
|
|
(tariff.min_stay && tariff.min_stay > 1 && tariff.available_for_min_stay === true)
|
|
const minStayBadge = isEffectivelyAvailable && !noRoomsAvailable && tariff.min_stay && tariff.min_stay > 1 ? (
|
|
<span style={styles.minStayBadge} title={`Minimum ${tariff.min_stay} nights`}>
|
|
{tariff.min_stay}
|
|
</span>
|
|
) : null
|
|
const getCellStyle = () => {
|
|
if (noRoomsAvailable) return styles.cellNoRooms
|
|
if (isEffectivelyAvailable) return styles.cellAvailable
|
|
return styles.cellUnavailable
|
|
}
|
|
const getTooltip = () => {
|
|
if (noRoomsAvailable) return `${tariffName}: No rooms available`
|
|
if (isEffectivelyAvailable) {
|
|
const minStayNote = tariff.min_stay && tariff.min_stay > 1 ? ` (Min ${tariff.min_stay} nights)` : ''
|
|
return `${tariffName}: ${formatCurrency(tariff.rate)}${minStayNote}`
|
|
}
|
|
return `${tariffName}: ${tariff.message || 'Not available'}`
|
|
}
|
|
|
|
return (
|
|
<td
|
|
key={dateStr}
|
|
style={mergeStyles(
|
|
styles.td,
|
|
styles.tariffCell,
|
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
|
getCellStyle(),
|
|
!isEffectivelyAvailable && !noRoomsAvailable ? { textDecoration: 'line-through' } : {},
|
|
isPast(dateStr) ? { opacity: 0.45 } : {}
|
|
)}
|
|
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')}
|
|
{minStayBadge}
|
|
</span>
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
))
|
|
)}
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
{/* Booking.com section */}
|
|
{bookingData && bookingData.has_own_hotel && (
|
|
<React.Fragment>
|
|
<tr>
|
|
<td style={mergeStyles(styles.categoryHeaderRow, styles.stickyCol, { zIndex: 15, overflow: 'visible', textOverflow: 'clip', width: 'auto', minWidth: '160px' })}>
|
|
Booking.com
|
|
<span style={styles.roomCount}>
|
|
{' '}{bookingData.latest_scrape ? `Scraped ${formatScrapeAge(bookingData.latest_scrape)}` : 'No scrape data'}
|
|
{' · '}
|
|
<Link to="/market" style={{ color: 'var(--navy)', textDecoration: 'none', fontSize: '13px' }}>
|
|
View details
|
|
</Link>
|
|
</span>
|
|
</td>
|
|
{data.dates.map(dateStr => (
|
|
<td key={dateStr} style={styles.categoryHeaderFill} />
|
|
))}
|
|
</tr>
|
|
<tr>
|
|
<td style={mergeStyles(styles.td, styles.stickyCol, styles.tariffNameCell)}>
|
|
Best Available
|
|
</td>
|
|
{data.dates.map(dateStr => {
|
|
const entry = bookingData.dates[dateStr]
|
|
const isAvailable = entry?.status === 'available'
|
|
const isSoldOut = entry?.status === 'sold_out'
|
|
const getCellStyle = () => {
|
|
if (!entry) return styles.cellNoData
|
|
if (isAvailable && entry.rate) return styles.cellAvailable
|
|
if (isSoldOut) return styles.bookingCellSoldOut
|
|
return styles.cellNoData
|
|
}
|
|
return (
|
|
<td
|
|
key={dateStr}
|
|
style={mergeStyles(
|
|
styles.td,
|
|
styles.tariffCell,
|
|
isWeekend(dateStr) ? styles.weekendCell : {},
|
|
getCellStyle()
|
|
)}
|
|
title={
|
|
!entry ? 'No data'
|
|
: isAvailable ? `Booking.com: ${formatCurrency(entry.rate)}`
|
|
: isSoldOut ? 'Sold out on Booking.com'
|
|
: 'No data'
|
|
}
|
|
>
|
|
{!entry ? '-'
|
|
: isAvailable && entry.rate ? formatCurrency(entry.rate)
|
|
: isSoldOut ? 'Sold'
|
|
: '-'}
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
</React.Fragment>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Issues Panel */}
|
|
{summary && summary.unbookableDateCategories > 0 && (
|
|
<div style={styles.issuesPanel}>
|
|
<h3 style={styles.issuesTitle}>
|
|
Unbookable Dates ({summary.totalIssues})
|
|
</h3>
|
|
<p style={styles.issuesSubtitle}>
|
|
Dates with rooms available but no rates bookable
|
|
</p>
|
|
<div style={styles.issuesList}>
|
|
{summary.issues.map((issue, idx) => (
|
|
<div key={idx} style={styles.issueItem}>
|
|
<span style={styles.issueCategory}>{issue.category}</span>
|
|
<span style={styles.issueDate}>{issue.date}</span>
|
|
<span style={styles.issueMessage}>{issue.roomsLeft} room{issue.roomsLeft !== 1 ? 's' : ''} available, no rates</span>
|
|
</div>
|
|
))}
|
|
{summary.hasMoreIssues && (
|
|
<div style={styles.moreIssues}>
|
|
+{summary.totalIssues - 10} more issues
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div style={styles.legend}>
|
|
<span style={styles.legendTitle}>Legend:</span>
|
|
<span style={mergeStyles(styles.legendItem, styles.cellAvailable)}>Available</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.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>
|
|
)
|
|
}
|
|
|
|
// Styles
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
container: {
|
|
padding: '24px',
|
|
maxWidth: '100%',
|
|
margin: '0 auto',
|
|
},
|
|
header: {
|
|
marginBottom: '24px',
|
|
},
|
|
headerTop: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'flex-start',
|
|
marginBottom: '16px',
|
|
flexWrap: 'wrap',
|
|
gap: '16px',
|
|
},
|
|
title: {
|
|
fontSize: '24px',
|
|
fontWeight: 700,
|
|
color: 'var(--text-dark)',
|
|
margin: 0,
|
|
},
|
|
subtitle: {
|
|
fontSize: '13px',
|
|
color: 'var(--text-mid)',
|
|
margin: '4px 0 0',
|
|
},
|
|
headerActions: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
},
|
|
monthSelector: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
},
|
|
monthDropdown: {
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
color: 'var(--text-dark)',
|
|
padding: '4px 8px',
|
|
borderRadius: '6px',
|
|
border: '1px solid var(--card-border)',
|
|
background: 'var(--card-bg)',
|
|
cursor: 'pointer',
|
|
minWidth: '160px',
|
|
},
|
|
summaryBar: {
|
|
display: 'flex',
|
|
gap: '24px',
|
|
padding: '16px',
|
|
background: 'var(--card-bg)',
|
|
borderRadius: '10px',
|
|
boxShadow: 'var(--shadow-sm)',
|
|
flexWrap: 'wrap',
|
|
},
|
|
summaryItem: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
},
|
|
summaryValue: {
|
|
fontSize: '20px',
|
|
fontWeight: 700,
|
|
color: 'var(--text-dark)',
|
|
},
|
|
summaryLabel: {
|
|
fontSize: '11px',
|
|
color: 'var(--text-mid)',
|
|
textTransform: 'uppercase',
|
|
},
|
|
content: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '24px',
|
|
},
|
|
unifiedCard: {
|
|
background: 'var(--card-bg)',
|
|
borderRadius: '10px',
|
|
padding: '16px',
|
|
boxShadow: 'var(--shadow-md)',
|
|
},
|
|
categoryHeaderRow: {
|
|
fontWeight: 600,
|
|
fontSize: '14px',
|
|
color: 'var(--text-dark)',
|
|
background: 'var(--body-bg)',
|
|
padding: '8px 16px',
|
|
borderTop: '2px solid var(--card-border)',
|
|
textAlign: 'left' as const,
|
|
whiteSpace: 'nowrap' as const,
|
|
},
|
|
categoryHeaderFill: {
|
|
background: 'var(--body-bg)',
|
|
borderTop: '2px solid var(--card-border)',
|
|
padding: 0,
|
|
},
|
|
stickyHeader: {
|
|
position: 'sticky' as const,
|
|
top: 0,
|
|
zIndex: 20,
|
|
background: 'var(--card-bg)',
|
|
},
|
|
roomCount: {
|
|
fontSize: '13px',
|
|
fontWeight: 400,
|
|
color: 'var(--text-mid)',
|
|
},
|
|
tableContainer: {
|
|
overflowX: 'auto',
|
|
maxWidth: '100%',
|
|
},
|
|
table: {
|
|
width: '100%',
|
|
borderCollapse: 'collapse',
|
|
fontSize: '13px',
|
|
minWidth: '800px',
|
|
tableLayout: 'fixed' as const,
|
|
},
|
|
th: {
|
|
padding: '8px',
|
|
borderBottom: '2px solid var(--card-border)',
|
|
textAlign: 'center',
|
|
fontWeight: 600,
|
|
color: 'var(--text-dark)',
|
|
whiteSpace: 'nowrap',
|
|
background: 'var(--card-bg)',
|
|
},
|
|
td: {
|
|
padding: '8px',
|
|
borderBottom: '1px solid var(--card-border)',
|
|
textAlign: 'center',
|
|
whiteSpace: 'nowrap',
|
|
},
|
|
stickyCol: {
|
|
position: 'sticky',
|
|
left: 0,
|
|
background: 'var(--card-bg)',
|
|
zIndex: 10,
|
|
textAlign: 'left',
|
|
width: '160px',
|
|
borderRight: '1px solid var(--card-border)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
dateHeader: {
|
|
width: '56px',
|
|
padding: '4px',
|
|
},
|
|
dateHeaderContent: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: '2px',
|
|
},
|
|
dayOfWeek: {
|
|
fontSize: '11px',
|
|
color: 'var(--text-mid)',
|
|
},
|
|
dayNum: {
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
},
|
|
lastUpdated: {
|
|
fontSize: '9px',
|
|
color: 'var(--text-mid)',
|
|
opacity: 0.7,
|
|
lineHeight: 1,
|
|
},
|
|
weekendHeader: {
|
|
background: 'var(--body-bg)',
|
|
},
|
|
weekendCell: {
|
|
borderLeft: '2px solid var(--card-border)',
|
|
},
|
|
tariffNameCell: {
|
|
fontWeight: 500,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
tariffCell: {
|
|
cursor: 'pointer',
|
|
position: 'relative',
|
|
transition: 'background 0.1s',
|
|
fontSize: '11px',
|
|
},
|
|
cellContent: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '2px',
|
|
},
|
|
minStayBadge: {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '14px',
|
|
height: '14px',
|
|
borderRadius: '50%',
|
|
background: '#d97706',
|
|
color: '#fff',
|
|
fontSize: '9px',
|
|
fontWeight: 700,
|
|
marginLeft: '2px',
|
|
flexShrink: 0,
|
|
},
|
|
cellAvailable: {
|
|
background: '#dcfce7',
|
|
color: 'var(--success)',
|
|
},
|
|
cellUnavailable: {
|
|
background: '#fee2e2',
|
|
color: 'var(--danger)',
|
|
textDecoration: 'line-through',
|
|
},
|
|
cellNoRooms: {
|
|
background: '#e0e0e0',
|
|
color: 'var(--text-mid)',
|
|
},
|
|
cellNoData: {
|
|
background: 'var(--body-bg)',
|
|
color: 'var(--text-mid)',
|
|
},
|
|
bookingCellSoldOut: {
|
|
background: '#fee2e2',
|
|
color: 'var(--danger)',
|
|
fontWeight: 500,
|
|
},
|
|
occupancyLabel: {
|
|
fontWeight: 600,
|
|
color: 'var(--navy)',
|
|
background: '#f0f4ff',
|
|
},
|
|
occupancyCell: {
|
|
fontSize: '11px',
|
|
color: 'var(--text-mid)',
|
|
background: 'var(--body-bg)',
|
|
},
|
|
occupancyText: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '2px',
|
|
},
|
|
maintenanceBadge: {
|
|
color: '#d97706',
|
|
marginLeft: '2px',
|
|
},
|
|
occupancyAvailable: {
|
|
background: '#dcfce7',
|
|
color: 'var(--success)',
|
|
fontWeight: 500,
|
|
},
|
|
occupancyHigh: {
|
|
background: '#fef3c7',
|
|
color: '#d97706',
|
|
fontWeight: 500,
|
|
},
|
|
occupancyFull: {
|
|
background: '#fee2e2',
|
|
color: 'var(--danger)',
|
|
fontWeight: 500,
|
|
},
|
|
loading: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: '48px',
|
|
gap: '16px',
|
|
color: 'var(--text-mid)',
|
|
},
|
|
spinner: {
|
|
width: '40px',
|
|
height: '40px',
|
|
border: '3px solid var(--card-border)',
|
|
borderTop: '3px solid var(--navy)',
|
|
borderRadius: '50%',
|
|
animation: 'spin 1s linear infinite',
|
|
},
|
|
error: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
padding: '24px',
|
|
background: '#fee2e2',
|
|
color: 'var(--danger)',
|
|
borderRadius: '10px',
|
|
},
|
|
errorIcon: {
|
|
width: '24px',
|
|
height: '24px',
|
|
borderRadius: '50%',
|
|
background: 'var(--danger)',
|
|
color: '#fff',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontWeight: 700,
|
|
},
|
|
noData: {
|
|
textAlign: 'center',
|
|
padding: '32px',
|
|
color: 'var(--text-mid)',
|
|
},
|
|
issuesPanel: {
|
|
marginTop: '24px',
|
|
background: '#fef3c7',
|
|
borderRadius: '10px',
|
|
padding: '24px',
|
|
},
|
|
issuesTitle: {
|
|
fontSize: '14px',
|
|
fontWeight: 600,
|
|
color: '#d97706',
|
|
marginBottom: '4px',
|
|
},
|
|
issuesSubtitle: {
|
|
fontSize: '13px',
|
|
color: 'var(--text-mid)',
|
|
marginBottom: '16px',
|
|
},
|
|
issuesList: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
},
|
|
issueItem: {
|
|
display: 'flex',
|
|
gap: '8px',
|
|
fontSize: '13px',
|
|
flexWrap: 'wrap',
|
|
},
|
|
issueCategory: {
|
|
fontWeight: 600,
|
|
color: 'var(--text-dark)',
|
|
},
|
|
issueDate: {
|
|
color: 'var(--text-mid)',
|
|
},
|
|
issueMessage: {
|
|
color: 'var(--danger)',
|
|
fontStyle: 'italic',
|
|
},
|
|
moreIssues: {
|
|
color: 'var(--text-mid)',
|
|
fontStyle: 'italic',
|
|
marginTop: '8px',
|
|
},
|
|
legend: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
marginTop: '24px',
|
|
padding: '16px',
|
|
background: 'var(--card-bg)',
|
|
borderRadius: '10px',
|
|
fontSize: '13px',
|
|
},
|
|
legendTitle: {
|
|
fontWeight: 600,
|
|
color: 'var(--text-dark)',
|
|
},
|
|
legendItem: {
|
|
padding: '4px 8px',
|
|
borderRadius: '4px',
|
|
fontSize: '11px',
|
|
},
|
|
scrapeBtn: {
|
|
background: 'none',
|
|
border: '1px solid var(--card-border)',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '10px',
|
|
lineHeight: 1,
|
|
padding: '2px 4px',
|
|
color: 'var(--text-mid)',
|
|
opacity: 0.6,
|
|
transition: 'opacity 0.15s',
|
|
},
|
|
scrapeBtnActive: {
|
|
opacity: 1,
|
|
color: 'var(--navy)',
|
|
borderColor: 'var(--navy)',
|
|
},
|
|
}
|
|
|
|
// ─── 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
|