rates/frontend/src/pages/Settings.tsx
jtricerolph ad0e82465c Parity: best-available comparison as default, term-matching optional
parity_match_mode config ('best_available' default | 'match_terms').
Best available = cheapest bookable non-dinner direct tariff vs the BC
lead-in (already BC's best available) — simplest like-for-like. Term
matching kept as an option for days where BC's cheapest basis differs
from direct's. Settings gains a comparison-basis select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:04:15 +00:00

774 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown, Network, ShieldCheck } from 'lucide-react'
import api from '../api'
const TABS = [
{ id: 'newbook', label: 'Newbook Sync' },
{ id: 'proxy', label: 'Scraper Proxy' },
{ id: 'parity', label: 'Rate Parity' },
{ id: 'system', label: 'System' },
]
interface SystemConfig {
[key: string]: string | null
}
interface RoomCategory {
id: number
site_id: string
site_name: string
room_count: number
is_included: boolean
display_order: number
}
export default function Settings() {
const { tab: tabParam } = useParams<{ tab?: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const activeTab = tabParam || 'newbook'
const { data: config, isLoading } = useQuery<SystemConfig>({
queryKey: ['system-config'],
queryFn: () => api.get('/competitors/config/system').then(r => r.data),
})
const saveMutation = useMutation({
mutationFn: (payload: { key: string; value: string }) =>
api.post('/competitors/config/system', payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }),
})
const syncNow = useMutation({
mutationFn: () => api.post('/bookability/refresh-rates'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }),
})
return (
<div>
<div className="page-header">
<div>
<div className="page-title">Settings</div>
<div className="page-subtitle">Newbook sync and system configuration</div>
</div>
</div>
<div className="sub-nav">
{TABS.map(t => (
<button
key={t.id}
className={`sub-nav-item${activeTab === t.id ? ' active' : ''}`}
onClick={() => navigate(`/settings/${t.id}`)}
>
{t.label}
</button>
))}
</div>
{activeTab === 'newbook' && (
<NewbookTab
config={config}
isLoading={isLoading}
onSave={(key, val) => saveMutation.mutate({ key, value: val })}
onSyncNow={() => syncNow.mutate()}
saving={saveMutation.isPending}
syncing={syncNow.isPending}
/>
)}
{activeTab === 'proxy' && (
<ProxyTab />
)}
{activeTab === 'parity' && (
<ParityTab
config={config}
isLoading={isLoading}
onSave={(key, val) => saveMutation.mutate({ key, value: val })}
saving={saveMutation.isPending}
/>
)}
{activeTab === 'system' && (
<SystemTab config={config} isLoading={isLoading} />
)}
</div>
)
}
// ─── Rate Parity Tab ──────────────────────────────────────────────────────────
interface ParityCheckResult {
status: string
dates_compared?: number
created?: number
updated?: number
resolved?: number
}
function ParityTab({ config, isLoading, onSave, saving }: {
config: SystemConfig | undefined
isLoading: boolean
onSave: (key: string, value: string) => void
saving: boolean
}) {
const [markup, setMarkup] = useState('')
const [markupUnit, setMarkupUnit] = useState('pct')
const [tolerance, setTolerance] = useState('')
const [toleranceUnit, setToleranceUnit] = useState('pct')
const [loaded, setLoaded] = useState(false)
const [checkResult, setCheckResult] = useState<ParityCheckResult | null>(null)
useEffect(() => {
if (config && !loaded) {
setMarkup(config['parity_markup_value'] ?? config['parity_expected_markup_pct'] ?? '0')
setMarkupUnit(config['parity_markup_unit'] ?? 'pct')
setTolerance(config['parity_tolerance_value'] ?? config['parity_tolerance_pct'] ?? '2')
setToleranceUnit(config['parity_tolerance_unit'] ?? 'pct')
setLoaded(true)
}
}, [config, loaded])
const enabled = (config?.['parity_check_enabled'] ?? 'true').toLowerCase() !== 'false'
const runCheck = useMutation({
mutationFn: () => api.post('/competitors/parity/check').then(r => r.data),
onSuccess: (data) => setCheckResult(data),
})
if (isLoading) return <div className="loading-state"><div className="spinner" />Loading</div>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 720 }}>
<div className="card">
<div className="card-header">
<ShieldCheck size={15} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 6 }} />
Rate Parity Check
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ fontSize: 12, color: 'var(--text-mid)' }}>
Compares our own hotel's Booking.com rate against the Newbook rate each morning (06:45).
We deliberately price Booking.com higher to cover commission, so the check measures against
an <strong>expected markup</strong> rather than raw equality: alert when Booking.com deviates
from Newbook × (1 + markup) by more than the tolerance. Alerts appear on the Market View
badge; acknowledging a date suppresses re-alerts for it, and dates that come back in line
auto-resolve.
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13 }}>
<input
type="checkbox"
checked={enabled}
onChange={e => onSave('parity_check_enabled', e.target.checked ? 'true' : 'false')}
style={{ width: 15, height: 15, accentColor: 'var(--gold)' }}
/>
Run daily parity check
</label>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
Comparison basis
</label>
<select
style={{ width: 330 }}
value={(config?.['parity_match_mode'] ?? 'best_available')}
onChange={e => onSave('parity_match_mode', e.target.value)}
>
<option value="best_available">Best available (cheapest bookable tariff on both sides)</option>
<option value="match_terms">Match terms (flex vs flex, prepaid vs prepaid)</option>
</select>
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4, maxWidth: 480 }}>
Best available compares our cheapest bookable direct tariff against the Booking.com
lead-in rate (Booking.com's best available). Match terms instead picks the direct tariff
with the same conditions as the scraped Booking.com rate only differs on days where
Booking.com's cheapest is flexible while a cheaper prepaid exists direct.
</div>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
Expected Booking.com markup
</label>
<div style={{ display: 'flex', gap: 6 }}>
<input
type="number" step="0.5" style={{ width: 110 }}
value={markup} onChange={e => setMarkup(e.target.value)}
placeholder={markupUnit === 'gbp' ? 'e.g. 20' : 'e.g. 15'}
/>
<select style={{ width: 64 }} value={markupUnit} onChange={e => setMarkupUnit(e.target.value)}>
<option value="pct">%</option>
<option value="gbp">£</option>
</select>
</div>
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
How much higher Booking.com should be than Newbook
{markupUnit === 'gbp' ? ' (flat £ per night)' : ' (percentage)'}.
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
Tolerance (±)
</label>
<div style={{ display: 'flex', gap: 6 }}>
<input
type="number" step="0.5" style={{ width: 110 }}
value={tolerance} onChange={e => setTolerance(e.target.value)}
placeholder={toleranceUnit === 'gbp' ? 'e.g. 5' : 'e.g. 2'}
/>
<select style={{ width: 64 }} value={toleranceUnit} onChange={e => setToleranceUnit(e.target.value)}>
<option value="pct">%</option>
<option value="gbp">£</option>
</select>
</div>
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
Allowed deviation from expected before alerting.
</div>
</div>
</div>
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 6, fontSize: 12, color: 'var(--text-mid)' }}>
Rule: alert when Booking.com ≠ Newbook {markupUnit === 'gbp' ? `+ £${markup || '0'}` : `+ ${markup || '0'}%`}
{' '}beyond ±{toleranceUnit === 'gbp' ? `£${tolerance || '0'}` : `${tolerance || '0'}%`}.
{' '}Example: Newbook £100 → expect £{(markupUnit === 'gbp'
? 100 + (parseFloat(markup) || 0)
: 100 * (1 + (parseFloat(markup) || 0) / 100)).toFixed(0)},
{' '}alert outside £{(() => {
const exp = markupUnit === 'gbp' ? 100 + (parseFloat(markup) || 0) : 100 * (1 + (parseFloat(markup) || 0) / 100)
const tol = toleranceUnit === 'gbp' ? (parseFloat(tolerance) || 0) : exp * (parseFloat(tolerance) || 0) / 100
return `${(exp - tol).toFixed(0)}–£${(exp + tol).toFixed(0)}`
})()}.
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
disabled={saving}
onClick={() => {
onSave('parity_markup_value', markup || '0')
onSave('parity_markup_unit', markupUnit)
onSave('parity_tolerance_value', tolerance || '2')
onSave('parity_tolerance_unit', toleranceUnit)
}}
>
<Save size={13} strokeWidth={1.75} />
Save
</button>
<button
className="btn btn-outline"
disabled={runCheck.isPending}
onClick={() => runCheck.mutate()}
>
<RefreshCw size={13} strokeWidth={1.75} />
{runCheck.isPending ? 'Checking' : 'Run check now'}
</button>
</div>
{checkResult && (
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, border: '1px solid #bbf7d0', fontSize: 13 }}>
{checkResult.status === 'disabled'
? 'Check is disabled enable it above first.'
: `Compared ${checkResult.dates_compared} dates — ${checkResult.created} new alerts, ${checkResult.updated} updated, ${checkResult.resolved} resolved.`}
</div>
)}
</div>
</div>
</div>
)
}
// ─── Scraper Proxy Tab ────────────────────────────────────────────────────────
interface ProxyConfigData {
enabled: boolean
host: string
port: string
username: string
country: string
password_set: boolean
}
interface ProxyTestResult {
ok: boolean
ip: string
country: string
city: string
org: string
}
function ProxyTab() {
const qc = useQueryClient()
const { data: cfg, isLoading } = useQuery<ProxyConfigData>({
queryKey: ['proxy-config'],
queryFn: () => api.get('/competitors/config/proxy').then(r => r.data),
})
const [enabled, setEnabled] = useState(false)
const [host, setHost] = useState('')
const [port, setPort] = useState('823')
const [username, setUsername] = useState('')
const [country, setCountry] = useState('gb')
const [password, setPassword] = useState('')
const [testResult, setTestResult] = useState<ProxyTestResult | null>(null)
const [testError, setTestError] = useState<string | null>(null)
const [workers, setWorkers] = useState('3')
const { data: sysCfg } = useQuery<SystemConfig>({
queryKey: ['system-config'],
queryFn: () => api.get('/competitors/config/system').then(r => r.data),
})
// Seed form once the stored config arrives (password stays blank by design)
useEffect(() => {
if (cfg) {
setEnabled(cfg.enabled)
setHost(cfg.host)
setPort(cfg.port)
setUsername(cfg.username)
setCountry(cfg.country)
}
}, [cfg])
useEffect(() => {
if (sysCfg?.booking_scraper_concurrency) setWorkers(String(sysCfg.booking_scraper_concurrency))
}, [sysCfg])
const saveWorkers = useMutation({
mutationFn: () => api.post('/competitors/config/system', {
key: 'booking_scraper_concurrency', value: String(Math.max(1, parseInt(workers) || 1)),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }),
})
const save = useMutation({
mutationFn: () =>
api.post('/competitors/config/proxy', {
enabled, host, port, username, country,
// Only send a password when the user typed a new one
password: password || undefined,
}),
onSuccess: () => {
setPassword('')
qc.invalidateQueries({ queryKey: ['proxy-config'] })
qc.invalidateQueries({ queryKey: ['system-config'] })
},
})
const runTest = useMutation({
mutationFn: () => api.post('/competitors/config/proxy/test').then(r => r.data as ProxyTestResult),
onMutate: () => { setTestResult(null); setTestError(null) },
onSuccess: (data) => setTestResult(data),
onError: (err: any) => setTestError(err?.response?.data?.detail || 'Test failed'),
})
if (isLoading) {
return <div className="loading-state"><div className="spinner" /> Loading…</div>
}
const labelStyle: React.CSSProperties = {
fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6,
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 600 }}>
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Network size={16} strokeWidth={1.75} />
Residential Proxy
</span>
<span className={`badge ${enabled ? 'badge-success' : 'badge-neutral'}`}>
{enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
Routes the Booking.com scraper through a residential proxy (e.g. DataImpulse).
One sticky IP is held per scrape session and rotated automatically if a page is
blocked. Leave disabled to scrape from this server's own IP.
</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
Enable proxy for scraping
</label>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Proxy host</label>
<input type="text" placeholder="gw.dataimpulse.com" value={host}
onChange={e => setHost(e.target.value)} style={{ width: '100%' }} />
</div>
<div>
<label style={labelStyle}>Port</label>
<input type="text" placeholder="823" value={port}
onChange={e => setPort(e.target.value)} style={{ width: '100%' }} />
</div>
</div>
<div>
<label style={labelStyle}>Username / login</label>
<input type="text" placeholder="account login" value={username}
onChange={e => setUsername(e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Password</label>
<input type="password" placeholder={cfg?.password_set ? '•••••••• (unchanged)' : 'not set'}
value={password} onChange={e => setPassword(e.target.value)} style={{ width: '100%' }} />
</div>
<div>
<label style={labelStyle}>Country</label>
<input type="text" placeholder="gb" value={country}
onChange={e => setCountry(e.target.value.toLowerCase())} style={{ width: '100%' }} />
</div>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button className="btn btn-primary" onClick={() => save.mutate()} disabled={save.isPending}>
<Save size={14} strokeWidth={1.75} />
{save.isPending ? 'Saving…' : 'Save'}
</button>
<button className="btn btn-outline" onClick={() => runTest.mutate()} disabled={runTest.isPending}>
<ShieldCheck size={14} strokeWidth={1.75} />
{runTest.isPending ? 'Testing…' : 'Test Connection'}
</button>
</div>
{save.isError && (
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>
{(save.error as any)?.response?.data?.detail || 'Save failed'}
</p>
)}
{testResult && (
<div style={{ fontSize: 13, color: 'var(--success)', background: 'var(--bg-subtle, #f0fdf4)',
padding: '10px 12px', borderRadius: 6, border: '1px solid var(--success)' }}>
Connected exit IP <strong>{testResult.ip}</strong> ({testResult.country}
{testResult.city ? `, ${testResult.city}` : ''}){testResult.org ? ` · ${testResult.org}` : ''}
</div>
)}
{testError && (
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>{testError}</p>
)}
</div>
</div>
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Network size={16} strokeWidth={1.75} />
Parallel Workers
</span>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
How many dates to scrape at once. Each worker runs its own browser on its own
residential IP, so a run finishes roughly this many times faster. Only takes effect
when the proxy is enabled (without it, all workers would share one IP). Budget about
0.4&nbsp;GB of RAM per worker 3 is safe on the current 4&nbsp;GB; raise it after
adding RAM.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="number" min={1} max={12} value={workers}
onChange={e => setWorkers(e.target.value)} style={{ width: 90 }} />
<button className="btn btn-primary btn-sm" onClick={() => saveWorkers.mutate()}
disabled={saveWorkers.isPending}>
<Save size={13} strokeWidth={1.75} />
{saveWorkers.isPending ? 'Saving…' : 'Save'}
</button>
{!enabled && (
<span style={{ fontSize: 12, color: 'var(--warning)' }}>
Enable the proxy above for this to apply
</span>
)}
</div>
</div>
</div>
</div>
)
}
// ─── Newbook Sync Tab ─────────────────────────────────────────────────────────
interface NewbookTabProps {
config: SystemConfig | undefined
isLoading: boolean
onSave: (key: string, val: string) => void
onSyncNow: () => void
saving: boolean
syncing: boolean
}
function NewbookTab({ config, isLoading, onSave, onSyncNow, saving, syncing }: NewbookTabProps) {
const [syncTime, setSyncTime] = useState('')
const syncEnabled = config?.sync_newbook_current_rates_enabled === 'true'
const currentTime = config?.sync_newbook_current_rates_time || '05:20'
if (isLoading) {
return <div className="loading-state"><div className="spinner" /> Loading</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 600 }}>
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Database size={16} strokeWidth={1.75} />
Newbook Rates Sync
</span>
<span className={`badge ${syncEnabled ? 'badge-success' : 'badge-neutral'}`}>
{syncEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
When enabled, the app fetches current tariff rates from the Newbook API daily and
stores them for the Bookability view and rate parity calculations.
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button
className={`btn ${syncEnabled ? 'btn-outline' : 'btn-primary'}`}
onClick={() => onSave('sync_newbook_current_rates_enabled', syncEnabled ? 'false' : 'true')}
disabled={saving}
>
{syncEnabled ? 'Disable Sync' : 'Enable Sync'}
</button>
<button
className="btn btn-outline"
onClick={onSyncNow}
disabled={syncing}
>
<RefreshCw size={14} strokeWidth={1.75} />
{syncing ? 'Refreshing…' : 'Sync Now'}
</button>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Clock size={16} strokeWidth={1.75} />
Sync Schedule
</span>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6 }}>
Daily sync time (HH:MM)
</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="time"
style={{ width: 130 }}
defaultValue={currentTime}
onChange={e => setSyncTime(e.target.value)}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => syncTime && onSave('sync_newbook_current_rates_time', syncTime)}
disabled={saving || !syncTime}
>
<Save size={13} strokeWidth={1.75} />
Save
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--text-mid)', marginTop: 6 }}>
Current: {currentTime} Booking.com scraper runs at {config?.booking_scraper_daily_time || '05:30'}
</p>
</div>
</div>
</div>
<RoomCategoriesCard />
</div>
)
}
// ─── Room Categories ──────────────────────────────────────────────────────────
function RoomCategoriesCard() {
const qc = useQueryClient()
const { data: categories, isLoading } = useQuery<RoomCategory[]>({
queryKey: ['room-categories'],
queryFn: () => api.get('/bookability/categories').then(r => r.data),
})
const syncCategories = useMutation({
mutationFn: () => api.post('/bookability/categories/sync'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
})
const toggleCategory = useMutation({
mutationFn: ({ id, included }: { id: number; included: boolean }) =>
api.patch(`/bookability/categories/${id}`, { is_included: included }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
})
const reorderCategories = useMutation({
mutationFn: (ordered: RoomCategory[]) =>
Promise.all(ordered.map((cat, idx) =>
api.patch(`/bookability/categories/${cat.id}`, { display_order: (idx + 1) * 10 })
)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
})
const moveCategory = (idx: number, dir: -1 | 1) => {
if (!categories) return
const target = idx + dir
if (target < 0 || target >= categories.length) return
const reordered = [...categories]
;[reordered[idx], reordered[target]] = [reordered[target], reordered[idx]]
reorderCategories.mutate(reordered)
}
return (
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BedDouble size={16} strokeWidth={1.75} />
Room Categories
</span>
<button
className="btn btn-outline btn-sm"
onClick={() => syncCategories.mutate()}
disabled={syncCategories.isPending}
>
<RefreshCw size={13} strokeWidth={1.75} />
{syncCategories.isPending ? 'Fetching…' : 'Fetch from Newbook'}
</button>
</div>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
Rate syncs only fetch tariffs for the included categories below. If the list is
empty, fetch categories from Newbook first otherwise syncs will do nothing.
</p>
{syncCategories.isError && (
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>
{(syncCategories.error as any)?.response?.data?.detail || 'Category fetch failed'}
</p>
)}
{isLoading ? (
<div className="loading-state"><div className="spinner" /> Loading</div>
) : !categories?.length ? (
<p style={{ fontSize: 13, color: 'var(--warning)', margin: 0 }}>
No room categories yet click Fetch from Newbook to load them.
</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Included</th>
<th>Category</th>
<th>Rooms</th>
<th>Order</th>
</tr>
</thead>
<tbody>
{categories.map((cat, idx) => (
<tr key={cat.id}>
<td>
<input
type="checkbox"
checked={cat.is_included}
onChange={e => toggleCategory.mutate({ id: cat.id, included: e.target.checked })}
disabled={toggleCategory.isPending}
/>
</td>
<td>{cat.site_name}</td>
<td>{cat.room_count}</td>
<td>
<span style={{ display: 'inline-flex', gap: 4 }}>
<button
className="btn btn-outline btn-sm"
style={{ padding: '2px 6px' }}
onClick={() => moveCategory(idx, -1)}
disabled={idx === 0 || reorderCategories.isPending}
title="Move up"
>
<ChevronUp size={13} strokeWidth={1.75} />
</button>
<button
className="btn btn-outline btn-sm"
style={{ padding: '2px 6px' }}
onClick={() => moveCategory(idx, 1)}
disabled={idx === categories.length - 1 || reorderCategories.isPending}
title="Move down"
>
<ChevronDown size={13} strokeWidth={1.75} />
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
// ─── System Tab ───────────────────────────────────────────────────────────────
function SystemTab({ config, isLoading }: { config: SystemConfig | undefined; isLoading: boolean }) {
if (isLoading) {
return <div className="loading-state"><div className="spinner" /> Loading</div>
}
const displayKeys = [
'booking_scraper_enabled',
'booking_scraper_backend',
'booking_scraper_daily_time',
'booking_proxy_enabled',
'booking_proxy_host',
'booking_proxy_country',
'booking_scraper_concurrency',
'sync_newbook_current_rates_enabled',
'sync_newbook_current_rates_time',
]
return (
<div style={{ maxWidth: 700 }}>
<div className="card">
<div className="card-header">System Configuration</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{displayKeys.map(k => (
<tr key={k}>
<td><code style={{ fontSize: 12, color: 'var(--text-mid)' }}>{k}</code></td>
<td>
<span style={{ fontSize: 13 }}>
{config?.[k] ?? <em style={{ color: 'var(--text-mid)' }}>not set</em>}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-mid)' }}>
To configure the Booking.com scraper location and hotel tiers, use the Settings tab inside{' '}
<a href="/rates/market" style={{ color: 'var(--gold)' }}>Market View</a>.
</div>
</div>
)
}