import React, { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // ── Stack theme compatibility shim (matches old theme.ts keys, new stack values) ── const colors = { primary: '#1a1a2e', primaryLight: '#2d2d44', primaryDark: '#16213e', accent: '#c9a84c', accentHover: '#b8973d', background: '#f4f5f7', surface: '#ffffff', surfaceHover: '#fafafa', text: '#1e293b', textSecondary: '#475569', textMuted: '#64748b', textLight: '#ffffff', border: '#e4e8ee', borderLight: '#eef1f5', borderFocus: '#1a1a2e', success: '#16a34a', successBg: '#dcfce7', warning: '#f59e0b', warningBg: '#fef3c7', error: '#dc2626', errorBg: '#fee2e2', info: '#0369a1', infoBg: '#e0f2fe', } const spacing = { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem', xl: '2rem', xxl: '3rem' } const radius = { sm: '4px', md: '6px', lg: '8px', xl: '12px', full: '9999px' } const shadows = { sm: '0 1px 2px rgba(16, 24, 40, 0.06)', md: '0 2px 8px rgba(16, 24, 40, 0.08)', lg: '0 4px 20px rgba(16, 24, 40, 0.12)', xl: '0 10px 40px rgba(16, 24, 40, 0.2)', } const typography = { fontFamily: "'Inter', system-ui, sans-serif", xs: '0.75rem', sm: '0.875rem', base: '1rem', lg: '1.125rem', xl: '1.25rem', xxl: '1.5rem', xxxl: '1.75rem', display: '2.5rem', normal: 400, medium: 500, semibold: 600, bold: 700, } const mergeStyles = (...styles: (React.CSSProperties | undefined)[]): React.CSSProperties => Object.assign({}, ...styles.filter(Boolean)) const BUTTON_BASE: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: `${spacing.sm} ${spacing.md}`, borderRadius: radius.md, fontSize: typography.sm, fontWeight: typography.semibold, cursor: 'pointer', transition: 'background 0.2s ease, transform 0.1s ease', border: 'none', outline: 'none', } const buttonStyle = ( variant: 'primary' | 'secondary' | 'outline' | 'ghost' = 'primary', size: 'small' | 'medium' | 'large' = 'medium' ): React.CSSProperties => { const variants: Record = { primary: { background: colors.accent, color: colors.textLight }, secondary: { background: colors.primary, color: colors.textLight }, outline: { background: 'transparent', color: colors.primary, border: `1px solid ${colors.border}` }, ghost: { background: 'transparent', color: colors.text }, } const sizes: Record = { small: { padding: `${spacing.xs} ${spacing.sm}`, fontSize: typography.xs }, medium: {}, large: { padding: `${spacing.md} ${spacing.lg}`, fontSize: typography.base }, } return mergeStyles(BUTTON_BASE, variants[variant], sizes[size]) } const badgeStyle = (status: 'success' | 'warning' | 'error' | 'info' = 'info'): React.CSSProperties => { const statuses: Record = { success: { background: colors.successBg, color: colors.success }, warning: { background: colors.warningBg, color: colors.warning }, error: { background: colors.errorBg, color: colors.error }, info: { background: colors.infoBg, color: colors.info }, } return mergeStyles({ display: 'inline-flex', alignItems: 'center', padding: `${spacing.xs} ${spacing.sm}`, borderRadius: radius.full, fontSize: typography.xs, fontWeight: typography.medium, }, statuses[status]) } type SettingsPage = 'newbook' | 'resos' | 'database' | 'special-dates' | 'budget' | 'tax-rates' | 'forecast-snapshots' | 'backup' | 'api-keys' | 'ai-insights' const Settings: React.FC = () => { const [activePage, setActivePage] = useState('newbook') const menuItems: { id: SettingsPage; label: string }[] = [ { id: 'newbook', label: 'Newbook' }, { id: 'resos', label: 'Resos' }, { id: 'special-dates', label: 'Special Dates' }, { id: 'budget', label: 'Budget' }, { id: 'tax-rates', label: 'Tax Rates' }, { id: 'forecast-snapshots', label: 'Forecast Snapshots' }, { id: 'ai-insights', label: 'AI Insights' }, { id: 'api-keys', label: 'API Keys' }, { id: 'backup', label: 'Backup & Restore' }, { id: 'database', label: 'Database Browser' }, ] return (

Settings

{activePage === 'newbook' && } {activePage === 'resos' && } {activePage === 'special-dates' && } {activePage === 'budget' && } {activePage === 'tax-rates' && } {activePage === 'forecast-snapshots' && } {activePage === 'backup' && } {activePage === 'database' && } {activePage === 'ai-insights' && } {activePage === 'api-keys' && }
) } // ============================================ // NEWBOOK SETTINGS PAGE // ============================================ interface NewbookSettings { newbook_api_key: string | null newbook_api_key_set: boolean newbook_username: string | null newbook_password_set: boolean newbook_region: string | null } interface RoomCategory { id: number site_id: string site_name: string site_type: string | null room_count: number is_included: boolean display_order: number } // ============================================ // ROOM CATEGORIES SECTION // ============================================ const RoomCategoriesSection: React.FC = () => { const queryClient = useQueryClient() const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') const [fetchMessage, setFetchMessage] = useState('') // Fetch room categories const { data: roomCategories, isLoading } = useQuery({ queryKey: ['room-categories'], queryFn: async () => { const response = await fetch('/forecasting/api/config/room-categories') if (!response.ok) return [] return response.json() }, }) // Fetch from Newbook API const handleFetch = async () => { setFetchStatus('fetching') setFetchMessage('') try { const response = await fetch('/forecasting/api/config/room-categories/fetch', { method: 'POST', }) const data = await response.json() if (response.ok) { setFetchStatus('success') setFetchMessage(data.message || 'Room categories fetched successfully') queryClient.invalidateQueries({ queryKey: ['room-categories'] }) } else { setFetchStatus('error') setFetchMessage(data.detail || 'Failed to fetch room categories') } } catch { setFetchStatus('error') setFetchMessage('Failed to fetch room categories') } setTimeout(() => { setFetchStatus('idle') setFetchMessage('') }, 5000) } // Update a single category (toggle included) const handleToggle = async (category: RoomCategory) => { try { await fetch('/forecasting/api/config/room-categories/bulk-update', { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ updates: [{ id: category.id, is_included: !category.is_included }] }) }) queryClient.invalidateQueries({ queryKey: ['room-categories'] }) } catch (err) { console.error('Failed to update room category', err) } } // Update display order for a category const handleOrderChange = async (category: RoomCategory, newOrder: number) => { try { await fetch('/forecasting/api/config/room-categories/bulk-update', { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ updates: [{ id: category.id, display_order: newOrder }] }) }) queryClient.invalidateQueries({ queryKey: ['room-categories'] }) } catch (err) { console.error('Failed to update display order', err) } } // Select/deselect all const handleSelectAll = async (include: boolean) => { if (!roomCategories?.length) return try { await fetch('/forecasting/api/config/room-categories/bulk-update', { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ updates: roomCategories.map(c => ({ id: c.id, is_included: include })) }) }) queryClient.invalidateQueries({ queryKey: ['room-categories'] }) } catch (err) { console.error('Failed to update room categories', err) } } const includedRooms = roomCategories?.filter(c => c.is_included).reduce((sum, c) => sum + (c.room_count || 0), 0) || 0 const includedTypes = roomCategories?.filter(c => c.is_included).length || 0 return (

Room Categories (for Occupancy)

Select which room types to include in occupancy and guest calculations. Exclude overflow rooms etc.

{roomCategories && roomCategories.length > 0 && ( <> )}
{fetchMessage && (
{fetchMessage}
)} {isLoading ? (
Loading room categories...
) : roomCategories && roomCategories.length > 0 ? ( <>
{includedRooms} rooms in {includedTypes} types selected
{roomCategories.map((cat) => (
handleToggle(cat)} style={styles.checkbox} /> {cat.site_name} {cat.room_count} rooms handleOrderChange(cat, parseInt(e.target.value) || 0)} style={styles.displayOrderInput} min={0} title="Display order (lower = first)" />
))}
) : (
No room categories loaded. Click "Fetch Room Categories" to load from Newbook.
)}
) } // ============================================ // GL REVENUE MAPPING SECTION // ============================================ interface GLAccount { id: number gl_account_id: string gl_code: string | null gl_name: string | null gl_group_id: string | null gl_group_name: string | null department: 'accommodation' | 'dry' | 'wet' | null is_active: boolean } type Department = 'accommodation' | 'dry' | 'wet' const DEPARTMENTS: { key: Department; label: string }[] = [ { key: 'accommodation', label: 'Accommodation' }, { key: 'dry', label: 'Dry (Food)' }, { key: 'wet', label: 'Wet (Beverage)' }, ] const GLRevenueMappingSection: React.FC = () => { const queryClient = useQueryClient() const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') const [fetchMessage, setFetchMessage] = useState('') const [modalDepartment, setModalDepartment] = useState(null) // Fetch GL accounts const { data: glAccounts, isLoading } = useQuery({ queryKey: ['gl-accounts'], queryFn: async () => { const response = await fetch('/forecasting/api/config/gl-accounts') if (!response.ok) return [] return response.json() }, }) // Fetch from Newbook API const handleFetch = async () => { setFetchStatus('fetching') setFetchMessage('') try { const response = await fetch('/forecasting/api/config/gl-accounts/fetch', { method: 'POST', }) const data = await response.json() if (response.ok) { setFetchStatus('success') setFetchMessage(data.message || 'GL accounts fetched successfully') queryClient.invalidateQueries({ queryKey: ['gl-accounts'] }) } else { setFetchStatus('error') setFetchMessage(data.detail || 'Failed to fetch GL accounts') } } catch { setFetchStatus('error') setFetchMessage('Failed to fetch GL accounts') } setTimeout(() => { setFetchStatus('idle') setFetchMessage('') }, 5000) } // Update department for accounts const handleUpdateDepartments = async (updates: { id: number; department: string | null }[]) => { try { await fetch('/forecasting/api/config/gl-accounts/department', { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ updates }) }) queryClient.invalidateQueries({ queryKey: ['gl-accounts'] }) } catch (err) { console.error('Failed to update GL accounts', err) } } // Get accounts for a specific department const getAccountsForDepartment = (dept: Department) => glAccounts?.filter(acc => acc.department === dept) || [] // Remove account from department const handleRemoveFromDepartment = (accountId: number) => { handleUpdateDepartments([{ id: accountId, department: null }]) } return (

GL Revenue Mapping

Map GL accounts to revenue departments for aggregation. Fetch accounts first, then assign to each department.

{glAccounts && glAccounts.length > 0 && ( {glAccounts.length} accounts loaded )}
{fetchMessage && (
{fetchMessage}
)} {isLoading ? (
Loading GL accounts...
) : glAccounts && glAccounts.length > 0 ? (
{DEPARTMENTS.map(dept => { const deptAccounts = getAccountsForDepartment(dept.key) return (
{dept.label} {deptAccounts.length} accounts
{deptAccounts.length === 0 ? (
No accounts mapped
) : ( deptAccounts.map(acc => (
{acc.gl_name || acc.gl_code || acc.gl_account_id} {acc.gl_code && {acc.gl_code}}
)) )}
) })}
) : (
No GL accounts loaded. Click "Fetch GL Accounts" to load from Newbook.
)} {/* GL Account Selection Modal */} {modalDepartment && glAccounts && ( d.key === modalDepartment)?.label || ''} glAccounts={glAccounts} onUpdate={handleUpdateDepartments} onClose={() => setModalDepartment(null)} /> )}
) } // GL Account Selection Modal Component interface GLAccountModalProps { department: Department departmentLabel: string glAccounts: GLAccount[] onUpdate: (updates: { id: number; department: string | null }[]) => void onClose: () => void } const GLAccountModal: React.FC = ({ department, departmentLabel, glAccounts, onUpdate, onClose }) => { // Group accounts by gl_group_name const grouped: Record = {} glAccounts.forEach(acc => { const groupName = acc.gl_group_name || 'Ungrouped' if (!grouped[groupName]) grouped[groupName] = [] grouped[groupName].push(acc) }) const sortedGroups = Object.keys(grouped).sort() // Check if account is selected for this department const isSelected = (acc: GLAccount) => acc.department === department // Toggle single account const handleToggle = (acc: GLAccount) => { if (isSelected(acc)) { onUpdate([{ id: acc.id, department: null }]) } else { onUpdate([{ id: acc.id, department }]) } } // Toggle entire group const handleGroupToggle = (groupAccounts: GLAccount[]) => { const allSelected = groupAccounts.every(acc => acc.department === department) if (allSelected) { // Deselect all in group onUpdate(groupAccounts.map(acc => ({ id: acc.id, department: null }))) } else { // Select all in group onUpdate(groupAccounts.map(acc => ({ id: acc.id, department }))) } } return (
e.stopPropagation()}>

Select GL Accounts for {departmentLabel}

{sortedGroups.map(groupName => { const groupAccounts = grouped[groupName] const selectedCount = groupAccounts.filter(acc => acc.department === department).length const allSelected = selectedCount === groupAccounts.length const someSelected = selectedCount > 0 && !allSelected return (
{groupAccounts.map(acc => ( ))}
) })}
) } // ============================================ // BOOKINGS DATA SYNC SECTION // ============================================ interface SyncStatus { last_successful_sync: { completed_at: string | null records_fetched: number | null records_created: number | null triggered_by: string | null } | null last_sync: { started_at: string | null completed_at: string | null status: string | null records_fetched: number | null error_message: string | null triggered_by: string | null } | null auto_sync: { enabled: boolean type: string time: string } total_records: number } interface SyncLog { id: number started_at: string completed_at: string | null status: string records_fetched: number | null records_created: number | null date_from: string | null date_to: string | null error_message: string | null triggered_by: string | null } const BookingsDataSyncSection: React.FC = () => { const queryClient = useQueryClient() const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') const [syncMessage, setSyncMessage] = useState('') const [fromDate, setFromDate] = useState('') const [toDate, setToDate] = useState('') const [autoEnabled, setAutoEnabled] = useState(false) const [autoType, setAutoType] = useState('incremental') const [syncTime, setSyncTime] = useState('05:00') // Fetch sync status const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ queryKey: ['bookings-sync-status'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/bookings-data/status') if (!response.ok) throw new Error('Failed to fetch status') return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Fetch sync logs const { data: logs, isLoading: logsLoading } = useQuery({ queryKey: ['bookings-sync-logs'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/bookings-data/logs?limit=5') if (!response.ok) return [] return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Update local state when status loads React.useEffect(() => { if (status?.auto_sync) { setAutoEnabled(status.auto_sync.enabled) setAutoType(status.auto_sync.type) setSyncTime(status.auto_sync.time || '05:00') } // Check if sync is currently running if (status?.last_sync?.status === 'running') { setSyncStatus('syncing') } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { // Sync completed setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') setSyncMessage(status?.last_sync?.status === 'success' ? `Synced ${status?.last_sync?.records_fetched || 0} bookings` : status?.last_sync?.error_message || 'Sync failed') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) queryClient.invalidateQueries({ queryKey: ['bookings-sync-status'] }) queryClient.invalidateQueries({ queryKey: ['bookings-sync-logs'] }) } }, [status, syncStatus, queryClient]) // Trigger sync const handleSync = async (mode: 'incremental' | 'staying_range') => { setSyncStatus('syncing') setSyncMessage('') try { let url = `/forecasting/api/sync/bookings-data/sync?sync_mode=${mode}` if (mode === 'staying_range' && fromDate && toDate) { url += `&from_date=${fromDate}&to_date=${toDate}` } const response = await fetch(url, { method: 'POST', }) const data = await response.json() if (response.ok) { setSyncMessage(data.message || 'Sync started...') // Keep polling via refetchInterval } else { setSyncStatus('error') setSyncMessage(data.detail || 'Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } catch { setSyncStatus('error') setSyncMessage('Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } // Update auto sync config const handleAutoConfigSave = async () => { try { const response = await fetch('/forecasting/api/sync/bookings-data/config', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: autoEnabled, sync_type: autoType, sync_time: syncTime }) }) if (response.ok) { refetchStatus() } } catch (err) { console.error('Failed to update config', err) } } const formatDate = (dateStr: string | null) => { if (!dateStr) return '-' return new Date(dateStr).toLocaleString() } const formatTrigger = (trigger: string | null) => { if (!trigger) return '-' if (trigger.startsWith('user:')) return trigger.replace('user:', '') if (trigger === 'scheduler') return 'Auto' return trigger } return (

Bookings Data Sync

Sync booking data from Newbook. Use date range for specific periods, or incremental for recent changes.

{statusLoading ? (
Loading sync status...
) : ( <> {/* Status summary */}
Total Records {status?.total_records?.toLocaleString() || 0}
Last Sync {status?.last_successful_sync?.completed_at ? formatDate(status.last_successful_sync.completed_at) : 'Never'}
Auto Sync {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'}
{/* Sync controls */}
{/* Incremental sync */}

Incremental Update

Fetches bookings modified since last sync (or last 7 days if no history).

{/* Date range sync */}

Date Range Sync

Fetches bookings staying during the specified date range.

setFromDate(e.target.value)} style={styles.dateInput} /> to setToDate(e.target.value)} style={styles.dateInput} />
{/* Auto sync config */}

Automatic Sync

Enable scheduled daily sync at configured time.

at setSyncTime(e.target.value)} style={styles.syncTimeInput} disabled={!autoEnabled} />
{/* Sync message */} {syncMessage && (
{syncMessage}
)} {/* Recent sync logs */}

Recent Syncs

{logsLoading ? (
Loading logs...
) : logs && logs.length > 0 ? (
{logs.map((log) => (
{log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} {formatDate(log.started_at)} {log.records_fetched !== null ? `${log.records_fetched} fetched` : ''} {log.records_created !== null ? `, ${log.records_created} new` : ''}
{formatTrigger(log.triggered_by)} {log.date_from && log.date_to && ( {log.date_from} → {log.date_to} )}
{log.error_message && (
{log.error_message}
)}
))}
) : (
No sync history yet.
)}
)}
) } // ============================================ // OCCUPANCY DATA SYNC SECTION // ============================================ interface OccupancySyncStatus { last_successful_sync: { completed_at: string | null records_fetched: number | null records_created: number | null date_from: string | null date_to: string | null triggered_by: string | null } | null last_sync: { started_at: string | null completed_at: string | null status: string | null records_fetched: number | null date_from: string | null date_to: string | null error_message: string | null triggered_by: string | null } | null auto_sync: { enabled: boolean time: string } total_records: number data_range: { from: string | null to: string | null } } const OccupancyDataSyncSection: React.FC = () => { const queryClient = useQueryClient() const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') const [syncMessage, setSyncMessage] = useState('') const [fromDate, setFromDate] = useState('') const [toDate, setToDate] = useState('') const [autoEnabled, setAutoEnabled] = useState(false) const [syncTime, setSyncTime] = useState('05:00') // Fetch sync status const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ queryKey: ['occupancy-sync-status'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/occupancy-data/status') if (!response.ok) throw new Error('Failed to fetch status') return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Fetch sync logs const { data: logs, isLoading: logsLoading } = useQuery({ queryKey: ['occupancy-sync-logs'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/occupancy-data/logs?limit=5') if (!response.ok) return [] return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Update local state when status loads React.useEffect(() => { if (status?.auto_sync) { setAutoEnabled(status.auto_sync.enabled) setSyncTime(status.auto_sync.time || '05:00') } // Check if sync is currently running if (status?.last_sync?.status === 'running') { setSyncStatus('syncing') } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { // Sync completed setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') setSyncMessage(status?.last_sync?.status === 'success' ? `Synced ${status?.last_sync?.records_fetched || 0} records` : status?.last_sync?.error_message || 'Sync failed') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) queryClient.invalidateQueries({ queryKey: ['occupancy-sync-status'] }) queryClient.invalidateQueries({ queryKey: ['occupancy-sync-logs'] }) } }, [status, syncStatus, queryClient]) // Trigger sync const handleSync = async () => { setSyncStatus('syncing') setSyncMessage('') try { let url = '/forecasting/api/sync/occupancy-data/sync' if (fromDate && toDate) { url += `?from_date=${fromDate}&to_date=${toDate}` } const response = await fetch(url, { method: 'POST', }) const data = await response.json() if (response.ok) { setSyncMessage(data.message || 'Sync started...') // Keep polling via refetchInterval } else { setSyncStatus('error') setSyncMessage(data.detail || 'Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } catch { setSyncStatus('error') setSyncMessage('Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } // Update auto sync config const handleAutoConfigSave = async () => { try { const response = await fetch('/forecasting/api/sync/occupancy-data/config', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: autoEnabled, sync_time: syncTime }) }) if (response.ok) { refetchStatus() } } catch (err) { console.error('Failed to update config', err) } } const formatDate = (dateStr: string | null) => { if (!dateStr) return '-' return new Date(dateStr).toLocaleString() } const formatTrigger = (trigger: string | null) => { if (!trigger) return '-' if (trigger.startsWith('user:')) return trigger.replace('user:', '') if (trigger === 'scheduler') return 'Auto' return trigger } return (

Occupancy Report Data Sync

Sync occupancy report data from Newbook. This includes available rooms, occupied, maintenance, and revenue per category.

{statusLoading ? (
Loading sync status...
) : ( <> {/* Status summary */}
Total Records {status?.total_records?.toLocaleString() || 0}
Data Range {status?.data_range?.from && status?.data_range?.to ? `${status.data_range.from} → ${status.data_range.to}` : 'No data'}
Last Sync {status?.last_successful_sync?.completed_at ? formatDate(status.last_successful_sync.completed_at) : 'Never'}
Auto Sync {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'}
{/* Sync controls */}
{/* Date range sync */}

Manual Sync

Sync occupancy data for date range. Default: -7 to +365 days if not specified.

setFromDate(e.target.value)} style={styles.dateInput} placeholder="From" /> to setToDate(e.target.value)} style={styles.dateInput} placeholder="To" />
{/* Auto sync config */}

Automatic Sync

Enable scheduled daily sync at configured time (-7 to +365 days).

Sync at setSyncTime(e.target.value)} style={styles.syncTimeInput} disabled={!autoEnabled} />
{/* Sync message */} {syncMessage && (
{syncMessage}
)} {/* Recent sync logs */}

Recent Syncs

{logsLoading ? (
Loading logs...
) : logs && logs.length > 0 ? (
{logs.map((log) => (
{log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} {formatDate(log.started_at)} {log.records_fetched !== null ? `${log.records_fetched} records` : ''}
{formatTrigger(log.triggered_by)} {log.date_from && log.date_to && ( {log.date_from} → {log.date_to} )}
{log.error_message && (
{log.error_message}
)}
))}
) : (
No sync history yet.
)}
)}
) } // ============================================ // EARNED REVENUE DATA SYNC SECTION // ============================================ interface EarnedRevenueSyncStatus { last_successful_sync: { completed_at: string | null records_fetched: number | null records_created: number | null date_from: string | null date_to: string | null triggered_by: string | null } | null last_sync: { started_at: string | null completed_at: string | null status: string | null records_fetched: number | null date_from: string | null date_to: string | null error_message: string | null triggered_by: string | null } | null auto_sync: { enabled: boolean time: string } total_records: number data_range: { from: string | null to: string | null } } const EarnedRevenueDataSyncSection: React.FC = () => { const queryClient = useQueryClient() const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') const [syncMessage, setSyncMessage] = useState('') const [fromDate, setFromDate] = useState('') const [toDate, setToDate] = useState('') const [autoEnabled, setAutoEnabled] = useState(false) const [syncTime, setSyncTime] = useState('05:10') // Fetch sync status const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ queryKey: ['earned-revenue-sync-status'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/earned-revenue-data/status') if (!response.ok) throw new Error('Failed to fetch status') return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Fetch sync logs const { data: logs, isLoading: logsLoading } = useQuery({ queryKey: ['earned-revenue-sync-logs'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/earned-revenue-data/logs?limit=5') if (!response.ok) return [] return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Update local state when status loads React.useEffect(() => { if (status?.auto_sync) { setAutoEnabled(status.auto_sync.enabled) setSyncTime(status.auto_sync.time || '05:10') } // Check if sync is currently running if (status?.last_sync?.status === 'running') { setSyncStatus('syncing') } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { // Sync completed setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') setSyncMessage(status?.last_sync?.status === 'success' ? `Synced ${status?.last_sync?.records_fetched || 0} records` : status?.last_sync?.error_message || 'Sync failed') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) queryClient.invalidateQueries({ queryKey: ['earned-revenue-sync-status'] }) queryClient.invalidateQueries({ queryKey: ['earned-revenue-sync-logs'] }) } }, [status, syncStatus, queryClient]) // Trigger sync const handleSync = async () => { setSyncStatus('syncing') setSyncMessage('') try { let url = '/forecasting/api/sync/earned-revenue-data/sync' if (fromDate && toDate) { url += `?from_date=${fromDate}&to_date=${toDate}` } const response = await fetch(url, { method: 'POST', }) const data = await response.json() if (response.ok) { setSyncMessage(data.message || 'Sync started...') // Keep polling via refetchInterval } else { setSyncStatus('error') setSyncMessage(data.detail || 'Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } catch { setSyncStatus('error') setSyncMessage('Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } // Update auto sync config const handleAutoConfigSave = async () => { try { const response = await fetch('/forecasting/api/sync/earned-revenue-data/config', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: autoEnabled, sync_time: syncTime }) }) if (response.ok) { refetchStatus() } } catch (err) { console.error('Failed to update config', err) } } const formatDate = (dateStr: string | null) => { if (!dateStr) return '-' return new Date(dateStr).toLocaleString() } const formatTrigger = (trigger: string | null) => { if (!trigger) return '-' if (trigger.startsWith('user:')) return trigger.replace('user:', '') if (trigger === 'scheduler') return 'Auto' return trigger } return (

Earned Revenue Data Sync

Sync earned revenue from Newbook (official GL figures). Used for revenue accuracy tracking.

{statusLoading ? (
Loading sync status...
) : ( <> {/* Status summary */}
Total Records {status?.total_records?.toLocaleString() || 0}
Data Range {status?.data_range?.from && status?.data_range?.to ? `${status.data_range.from} → ${status.data_range.to}` : 'No data'}
Last Sync {status?.last_successful_sync?.completed_at ? formatDate(status.last_successful_sync.completed_at) : 'Never'}
Auto Sync {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'}
{/* Sync controls */}
{/* Date range sync */}

Manual Sync

Sync earned revenue for date range. Default: last 7 days if not specified.

setFromDate(e.target.value)} style={styles.dateInput} placeholder="From" /> to setToDate(e.target.value)} style={styles.dateInput} placeholder="To" />
{/* Auto sync config */}

Automatic Sync

Enable scheduled daily sync at configured time (last 7 days).

Sync at setSyncTime(e.target.value)} style={styles.syncTimeInput} disabled={!autoEnabled} />
{/* Sync message */} {syncMessage && (
{syncMessage}
)} {/* Recent sync logs */}

Recent Syncs

{logsLoading ? (
Loading logs...
) : logs && logs.length > 0 ? (
{logs.map((log) => (
{log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} {formatDate(log.started_at)} {log.records_fetched !== null ? `${log.records_fetched} records` : ''}
{formatTrigger(log.triggered_by)} {log.date_from && log.date_to && ( {log.date_from} → {log.date_to} )}
{log.error_message && (
{log.error_message}
)}
))}
) : (
No sync history yet.
)}
)}
) } // ============================================ // CURRENT RATES DATA SYNC SECTION (Pickup-V2) // ============================================ interface CurrentRatesSyncStatus { last_successful_sync: { completed_at: string | null records_fetched: number | null records_created: number | null triggered_by: string | null } | null last_sync: { started_at: string | null completed_at: string | null status: string | null records_fetched: number | null error_message: string | null triggered_by: string | null } | null auto_sync: { enabled: boolean time: string } total_records: number data_range: { from: string | null to: string | null } category_counts: Record } const CurrentRatesDataSyncSection: React.FC = () => { const queryClient = useQueryClient() const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') const [syncMessage, setSyncMessage] = useState('') const [autoEnabled, setAutoEnabled] = useState(false) const [syncTime, setSyncTime] = useState('05:20') const [horizonDays, setHorizonDays] = useState('') // Empty = full 720-day run // Fetch sync status const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ queryKey: ['current-rates-sync-status'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/current-rates/status') if (!response.ok) throw new Error('Failed to fetch status') return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Fetch sync logs const { data: logs, isLoading: logsLoading } = useQuery({ queryKey: ['current-rates-sync-logs'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/current-rates/logs?limit=5') if (!response.ok) return [] return response.json() }, refetchInterval: syncStatus === 'syncing' ? 3000 : false, }) // Update local state when status loads React.useEffect(() => { if (status?.auto_sync) { setAutoEnabled(status.auto_sync.enabled) setSyncTime(status.auto_sync.time || '05:20') } // Check if sync is currently running if (status?.last_sync?.status === 'running') { setSyncStatus('syncing') } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { // Sync completed setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') setSyncMessage(status?.last_sync?.status === 'success' ? `Synced ${status?.last_sync?.records_fetched || 0} rates` : status?.last_sync?.error_message || 'Sync failed') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) queryClient.invalidateQueries({ queryKey: ['current-rates-sync-status'] }) queryClient.invalidateQueries({ queryKey: ['current-rates-sync-logs'] }) } }, [status, syncStatus, queryClient]) // Trigger sync const handleSync = async () => { setSyncStatus('syncing') setSyncMessage('') try { const body: Record = {} if (horizonDays && parseInt(horizonDays) > 0) { body.horizon_days = parseInt(horizonDays) } const response = await fetch('/forecasting/api/sync/current-rates/sync', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body) }) const data = await response.json() if (response.ok) { setSyncMessage(data.message || 'Sync started...') } else { setSyncStatus('error') setSyncMessage(data.detail || 'Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } catch { setSyncStatus('error') setSyncMessage('Failed to start sync') setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } } // Update auto sync config const handleAutoConfigSave = async () => { try { const response = await fetch('/forecasting/api/sync/current-rates/config', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: autoEnabled, sync_time: syncTime }) }) if (response.ok) { refetchStatus() } } catch (err) { console.error('Failed to update config', err) } } // Cancel running sync const handleCancelSync = async () => { try { const response = await fetch('/forecasting/api/sync/current-rates/cancel', { method: 'POST', }) const data = await response.json() if (response.ok) { setSyncStatus('idle') setSyncMessage(data.message || 'Sync cancelled') queryClient.invalidateQueries({ queryKey: ['current-rates-sync-status'] }) queryClient.invalidateQueries({ queryKey: ['current-rates-sync-logs'] }) setTimeout(() => { setSyncMessage('') }, 3000) } } catch { console.error('Failed to cancel sync') } } const formatDate = (dateStr: string | null) => { if (!dateStr) return '-' return new Date(dateStr).toLocaleString() } const formatTrigger = (trigger: string | null) => { if (!trigger) return '-' if (trigger.startsWith('user:')) return trigger.replace('user:', '') if (trigger === 'scheduler') return 'Auto' return trigger } return (

Current Rates Sync (Pickup-V2)

Fetches current rack rates from Newbook for revenue forecast upper bounds. Used by Pickup-V2 model for confidence shading.

{statusLoading ? (
Loading sync status...
) : ( <> {/* Status summary */}
Total Rates {status?.total_records?.toLocaleString() || 0}
Date Range {status?.data_range?.from && status?.data_range?.to ? `${status.data_range.from} → ${status.data_range.to}` : 'No data'}
Last Sync {status?.last_successful_sync?.completed_at ? formatDate(status.last_successful_sync.completed_at) : 'Never'}
Auto Sync {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'}
{/* Category breakdown if data exists */} {status?.category_counts && Object.keys(status.category_counts).length > 0 && (
Categories: {Object.entries(status.category_counts).map(([cat, count]) => `${cat}: ${count} days` ).join(', ')}
)} {/* Sync controls */}
{/* Manual sync */}

Manual Sync

Fetch rates for all categories. Leave days blank for full 720-day run.

Days ahead: setHorizonDays(e.target.value)} placeholder="720" min="1" max="720" disabled={syncStatus === 'syncing'} style={{ width: '80px', padding: `${spacing.xs} ${spacing.sm}`, border: `1px solid ${colors.border}`, borderRadius: '4px', fontSize: '0.85rem', backgroundColor: colors.surface, color: colors.text, }} />
{syncStatus === 'syncing' && ( )}
{/* Auto sync config */}

Automatic Sync

Enable scheduled daily sync. Fetches rates for next 365 days.

Sync at setSyncTime(e.target.value)} style={styles.syncTimeInput} disabled={!autoEnabled} />
{/* Sync message */} {syncMessage && (
{syncMessage}
)} {/* Recent sync logs */}

Recent Syncs

{logsLoading ? (
Loading logs...
) : logs && logs.length > 0 ? (
{logs.map((log) => (
{log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} {formatDate(log.started_at)} {log.records_fetched !== null ? `${log.records_fetched} rates` : ''}
{formatTrigger(log.triggered_by)} {log.status === 'running' && ( )}
{log.error_message && (
{log.error_message}
)}
))}
) : (
No sync history yet.
)}
)}
) } const NewbookPage: React.FC = () => { const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle') const [testMessage, setTestMessage] = useState('') const handleTestConnection = async () => { setTestStatus('testing') setTestMessage('') try { const response = await fetch('/forecasting/api/config/settings/newbook/test', { method: 'POST', }) const data = await response.json() if (response.ok) { setTestStatus('success') setTestMessage(data.message || 'Connection successful!') } else { setTestStatus('error') setTestMessage(data.detail || 'Connection failed') } } catch { setTestStatus('error') setTestMessage('Connection failed') } setTimeout(() => { setTestStatus('idle') setTestMessage('') }, 5000) } return (

Newbook Settings

Newbook data synchronization for this app.

Newbook API credentials are managed centrally in the stack Settings app (Integrations → NewBook) and shared by all apps. Use the button below to verify this app can reach Newbook with those credentials.
{testMessage && (
{testMessage}
)}
) } // ============================================ // RESOS SETTINGS PAGE // ============================================ interface ResosCustomField { id: string name: string type: string values?: string[] } interface CustomFieldMapping { custom_field_id: string mapping_type: string } interface ResosOpeningHour { id: string name: string start_time: string end_time: string } interface OpeningHourMapping { opening_hour_id: string period_type: string display_name?: string } interface ManualBreakfastPeriod { day_of_week: number start_time: string end_time: string is_active: boolean } const ResosPage: React.FC = () => { return (

Resos Settings

Configure Resos sync settings for restaurant reservation management. The Resos API key is managed centrally in the Settings app.

) } // ============================================ // RESOS CUSTOM FIELD MAPPING SECTION // ============================================ const ResosCustomFieldMappingSection: React.FC = () => { const queryClient = useQueryClient() const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') const [fetchMessage, setFetchMessage] = useState('') const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [saveMessage, setSaveMessage] = useState('') const [customFieldMapping, setCustomFieldMapping] = useState>({}) const { data: customFields, isLoading } = useQuery({ queryKey: ['resos-custom-fields-list'], queryFn: async () => { const response = await fetch('/forecasting/api/resos/custom-fields') if (!response.ok) return [] return response.json() }, }) const { data: existingMappings } = useQuery({ queryKey: ['resos-custom-field-mapping'], queryFn: async () => { const response = await fetch('/forecasting/api/resos/custom-field-mapping') if (!response.ok) return [] return response.json() }, }) React.useEffect(() => { if (existingMappings) { // Convert from array to simple mapping object: {mapping_type: field_id} const mappingObj: Record = {} existingMappings.forEach(m => { mappingObj[m.mapping_type] = m.custom_field_id }) setCustomFieldMapping(mappingObj) } }, [existingMappings]) const handleFetch = async () => { setFetchStatus('fetching') setFetchMessage('') try { const response = await fetch('/forecasting/api/resos/custom-fields', { method: 'GET', }) const data = await response.json() if (response.ok) { setFetchStatus('success') setFetchMessage('Custom fields fetched successfully') queryClient.invalidateQueries({ queryKey: ['resos-custom-fields-list'] }) } else { setFetchStatus('error') setFetchMessage(data.detail || 'Failed to fetch custom fields') } } catch { setFetchStatus('error') setFetchMessage('Failed to fetch custom fields') } setTimeout(() => { setFetchStatus('idle') setFetchMessage('') }, 5000) } const handleSaveMappings = async () => { setSaveStatus('saving') setSaveMessage('') try { // Convert mapping object back to array format for API const mappingsArray = Object.entries(customFieldMapping) .filter(([_, fieldId]) => fieldId) // Only include non-empty mappings .map(([mappingType, fieldId]) => ({ custom_field_id: fieldId, mapping_type: mappingType })) const response = await fetch('/forecasting/api/resos/custom-field-mapping', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ mappings: mappingsArray }) }) if (response.ok) { setSaveStatus('success') setSaveMessage('Mappings saved successfully') queryClient.invalidateQueries({ queryKey: ['resos-custom-field-mapping'] }) } else { const data = await response.json() setSaveStatus('error') setSaveMessage(data.detail || 'Failed to save mappings') } } catch { setSaveStatus('error') setSaveMessage('Failed to save mappings') } setTimeout(() => { setSaveStatus('idle') setSaveMessage('') }, 5000) } // Define predefined mapping targets (like kitchen app) const mappingTargets = [ { key: 'booking_number', label: 'Hotel Booking #', hint: 'Hotel booking reference number from Resos custom field' }, { key: 'hotel_guest', label: 'Hotel Guest', hint: 'Yes/No field indicating if diner is a hotel guest' }, { key: 'dbb', label: 'DBB (Dinner B&B)', hint: 'Yes/No field indicating Dinner Bed & Breakfast package guests' }, { key: 'package', label: 'Package', hint: 'Yes/No field indicating package deal bookings' }, { key: 'group_exclude', label: 'Group/Exclude', hint: 'Free-text field for group codes and exclusions (e.g., "#12345,NOT-#56789")' }, { key: 'allergies', label: 'Allergies', hint: 'Multi-select or text field with allergy information' }, ] return (

Custom Field Mapping

Map Resos custom fields to booking data fields. Fetch custom fields first, then select which Resos field maps to each target.

{customFields && customFields.length > 0 && ( {customFields.length} fields loaded )}
{fetchMessage && (
{fetchMessage}
)} {isLoading ? (
Loading custom fields...
) : customFields && customFields.length > 0 ? ( <>
{mappingTargets.map((target) => (
{target.hint}
))}
{saveMessage && (
{saveMessage}
)} ) : (
No custom fields loaded. Click "Fetch Custom Fields" to load from Resos.
)}
) } // ============================================ // RESOS OPENING HOURS MAPPING SECTION // ============================================ const ResosOpeningHoursMappingSection: React.FC = () => { const queryClient = useQueryClient() const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') const [fetchMessage, setFetchMessage] = useState('') const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [saveMessage, setSaveMessage] = useState('') const [mappings, setMappings] = useState>({}) const { data: openingHours, isLoading } = useQuery({ queryKey: ['resos-opening-hours-list'], queryFn: async () => { const response = await fetch('/forecasting/api/resos/opening-hours') if (!response.ok) return [] return response.json() }, }) const { data: existingMappings } = useQuery({ queryKey: ['resos-opening-hours-mapping'], queryFn: async () => { const response = await fetch('/forecasting/api/resos/opening-hours-mapping') if (!response.ok) return [] return response.json() }, }) React.useEffect(() => { if (existingMappings) { const mappingObj: Record = {} existingMappings.forEach(m => { mappingObj[m.opening_hour_id] = m }) setMappings(mappingObj) } }, [existingMappings]) const handleFetch = async () => { setFetchStatus('fetching') setFetchMessage('') try { const response = await fetch('/forecasting/api/resos/opening-hours', { method: 'GET', }) const data = await response.json() if (response.ok) { setFetchStatus('success') setFetchMessage('Opening hours fetched successfully') queryClient.invalidateQueries({ queryKey: ['resos-opening-hours-list'] }) } else { setFetchStatus('error') setFetchMessage(data.detail || 'Failed to fetch opening hours') } } catch { setFetchStatus('error') setFetchMessage('Failed to fetch opening hours') } setTimeout(() => { setFetchStatus('idle') setFetchMessage('') }, 5000) } const handleMappingChange = (hourId: string, periodType: string) => { setMappings(prev => ({ ...prev, [hourId]: { opening_hour_id: hourId, period_type: periodType, display_name: prev[hourId]?.display_name } })) } const handleDisplayNameChange = (hourId: string, displayName: string) => { setMappings(prev => ({ ...prev, [hourId]: { ...prev[hourId], opening_hour_id: hourId, period_type: prev[hourId]?.period_type || 'ignore', display_name: displayName || undefined } })) } const handleSaveMappings = async () => { setSaveStatus('saving') setSaveMessage('') try { const mappingsArray = Object.values(mappings).filter(m => m.period_type !== 'ignore') const response = await fetch('/forecasting/api/resos/opening-hours-mapping', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ mappings: mappingsArray }) }) if (response.ok) { setSaveStatus('success') setSaveMessage('Mappings saved successfully') queryClient.invalidateQueries({ queryKey: ['resos-opening-hours-mapping'] }) } else { const data = await response.json() setSaveStatus('error') setSaveMessage(data.detail || 'Failed to save mappings') } } catch { setSaveStatus('error') setSaveMessage('Failed to save mappings') } setTimeout(() => { setSaveStatus('idle') setSaveMessage('') }, 5000) } return (

Opening Hours Mapping

Map Resos opening hours to meal periods. Fetch opening hours first, then assign period types.

{openingHours && openingHours.length > 0 && ( {openingHours.length} hours loaded )}
{fetchMessage && (
{fetchMessage}
)} {isLoading ? (
Loading opening hours...
) : openingHours && openingHours.length > 0 ? ( <>
{openingHours.map((hour) => { const mapping = mappings[hour.id] const periodType = mapping?.period_type || 'ignore' return (
{hour.name}
{hour.start_time} - {hour.end_time}
handleDisplayNameChange(hour.id, e.target.value)} placeholder="Display name (optional)" style={styles.input} disabled={periodType === 'ignore'} />
) })}
{saveMessage && (
{saveMessage}
)} ) : (
No opening hours loaded. Click "Fetch Opening Hours" to load from Resos.
)}
) } // ============================================ // RESOS MANUAL BREAKFAST CONFIGURATION SECTION // ============================================ const ResosManualBreakfastSection: React.FC = () => { const [enabled, setEnabled] = useState(false) const [periods, setPeriods] = useState([ { day_of_week: 1, start_time: '07:00', end_time: '10:00', is_active: true }, { day_of_week: 2, start_time: '07:00', end_time: '10:00', is_active: true }, { day_of_week: 3, start_time: '07:00', end_time: '10:00', is_active: true }, { day_of_week: 4, start_time: '07:00', end_time: '10:00', is_active: true }, { day_of_week: 5, start_time: '07:00', end_time: '10:00', is_active: true }, { day_of_week: 6, start_time: '08:00', end_time: '11:00', is_active: true }, { day_of_week: 0, start_time: '08:00', end_time: '11:00', is_active: true }, ]) const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [saveMessage, setSaveMessage] = useState('') const { data: existingPeriods, isLoading } = useQuery<{ enabled: boolean; periods: ManualBreakfastPeriod[] }>({ queryKey: ['resos-manual-breakfast'], queryFn: async () => { const response = await fetch('/forecasting/api/resos/manual-breakfast-periods') if (!response.ok) return { enabled: false, periods: [] } return response.json() }, }) React.useEffect(() => { if (existingPeriods && existingPeriods.periods.length > 0) { setEnabled(existingPeriods.enabled) setPeriods(existingPeriods.periods) } }, [existingPeriods]) const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] const handlePeriodChange = (index: number, field: keyof ManualBreakfastPeriod, value: string | boolean) => { const newPeriods = [...periods] newPeriods[index] = { ...newPeriods[index], [field]: value } setPeriods(newPeriods) } const handleSave = async () => { setSaveStatus('saving') setSaveMessage('') try { const response = await fetch('/forecasting/api/resos/manual-breakfast-periods', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled, periods }) }) if (response.ok) { setSaveStatus('success') setSaveMessage('Manual breakfast configuration saved successfully') } else { const data = await response.json() setSaveStatus('error') setSaveMessage(data.detail || 'Failed to save configuration') } } catch { setSaveStatus('error') setSaveMessage('Failed to save configuration') } setTimeout(() => { setSaveStatus('idle') setSaveMessage('') }, 5000) } if (isLoading) { return
Loading manual breakfast configuration...
} return (

Manual Breakfast Configuration

Configure breakfast periods manually instead of using Resos opening hours. Useful for custom scheduling.

{enabled && ( <>
{periods.map((period, index) => (
{dayNames[period.day_of_week]}
handlePeriodChange(index, 'start_time', e.target.value)} style={styles.input} disabled={!period.is_active} /> handlePeriodChange(index, 'end_time', e.target.value)} style={styles.input} disabled={!period.is_active} />
))}
{saveMessage && (
{saveMessage}
)} )}
) } // ============================================ // RESOS AVERAGE SPEND CONFIGURATION SECTION // ============================================ const ResosAverageSpendSection: React.FC = () => { const queryClient = useQueryClient() const [breakfastFoodSpend, setBreakfastFoodSpend] = useState('') const [breakfastDrinksSpend, setBreakfastDrinksSpend] = useState('') const [lunchFoodSpend, setLunchFoodSpend] = useState('') const [lunchDrinksSpend, setLunchDrinksSpend] = useState('') const [dinnerFoodSpend, setDinnerFoodSpend] = useState('') const [dinnerDrinksSpend, setDinnerDrinksSpend] = useState('') const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [saveMessage, setSaveMessage] = useState('') const { data: spendSettings, isLoading } = useQuery({ queryKey: ['resos-average-spend'], queryFn: async () => { const response = await fetch('/forecasting/api/resos/average-spend') if (!response.ok) return null return response.json() }, }) // Update local state when settings load React.useEffect(() => { if (spendSettings) { setBreakfastFoodSpend(spendSettings.breakfast_food_spend?.toString() || '') setBreakfastDrinksSpend(spendSettings.breakfast_drinks_spend?.toString() || '') setLunchFoodSpend(spendSettings.lunch_food_spend?.toString() || '') setLunchDrinksSpend(spendSettings.lunch_drinks_spend?.toString() || '') setDinnerFoodSpend(spendSettings.dinner_food_spend?.toString() || '') setDinnerDrinksSpend(spendSettings.dinner_drinks_spend?.toString() || '') } }, [spendSettings]) const handleSave = async () => { setSaveStatus('saving') setSaveMessage('') try { const response = await fetch('/forecasting/api/resos/average-spend', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ breakfast_food_spend: parseFloat(breakfastFoodSpend) || 0, breakfast_drinks_spend: parseFloat(breakfastDrinksSpend) || 0, lunch_food_spend: parseFloat(lunchFoodSpend) || 0, lunch_drinks_spend: parseFloat(lunchDrinksSpend) || 0, dinner_food_spend: parseFloat(dinnerFoodSpend) || 0, dinner_drinks_spend: parseFloat(dinnerDrinksSpend) || 0 }) }) if (response.ok) { setSaveStatus('success') setSaveMessage('Average spend settings saved successfully') queryClient.invalidateQueries({ queryKey: ['resos-average-spend'] }) } else { const data = await response.json() setSaveStatus('error') setSaveMessage(data.detail || 'Failed to save settings') } } catch { setSaveStatus('error') setSaveMessage('Failed to save settings') } setTimeout(() => { setSaveStatus('idle') setSaveMessage('') }, 3000) } return (

Average Spend per Cover (Gross inc VAT)

Configure average spend values per cover for revenue forecasting. Enter gross amounts (including VAT) - the system will calculate net revenue at 20% VAT automatically. These are interim values until till integration provides live data.

{isLoading ? (
Loading settings...
) : ( <>
{/* Breakfast Section */}

Breakfast

{/* Lunch Section */}

Lunch

{/* Dinner Section */}

Dinner

{saveMessage && (
{saveMessage}
)} )}
) } // ============================================ // RESOS SYNC CONFIGURATION SECTION // ============================================ const ResosSyncConfigSection: React.FC = () => { const [autoSyncEnabled, setAutoSyncEnabled] = useState(false) const [syncTime, setSyncTime] = useState('03:00') const [fromDate, setFromDate] = useState('') const [toDate, setToDate] = useState('') const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [saveMessage, setSaveMessage] = useState('') const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') const [syncMessage, setSyncMessage] = useState('') const { data: syncConfig, isLoading: configLoading } = useQuery({ queryKey: ['resos-sync-config'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/resos-bookings/config') if (!response.ok) return null return response.json() }, }) const { data: lastSyncStatus, refetch: refetchStatus } = useQuery({ queryKey: ['resos-sync-status'], queryFn: async () => { const response = await fetch('/forecasting/api/sync/resos-bookings/status') if (!response.ok) return {} return response.json() }, refetchInterval: 30000, }) React.useEffect(() => { if (syncConfig) { setAutoSyncEnabled(syncConfig.auto_sync_enabled || false) setSyncTime(syncConfig.sync_time || '03:00') } }, [syncConfig]) React.useEffect(() => { const today = new Date() const sevenDaysAgo = new Date(today) sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) setFromDate(sevenDaysAgo.toISOString().split('T')[0]) setToDate(today.toISOString().split('T')[0]) }, []) const handleSaveConfig = async () => { setSaveStatus('saving') setSaveMessage('') try { const response = await fetch('/forecasting/api/sync/resos-bookings/config', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ auto_sync_enabled: autoSyncEnabled, sync_time: syncTime }) }) if (response.ok) { setSaveStatus('success') setSaveMessage('Sync configuration saved successfully') } else { const data = await response.json() setSaveStatus('error') setSaveMessage(data.detail || 'Failed to save configuration') } } catch { setSaveStatus('error') setSaveMessage('Failed to save configuration') } setTimeout(() => { setSaveStatus('idle') setSaveMessage('') }, 5000) } const handleTriggerSync = async () => { setSyncStatus('syncing') setSyncMessage('') try { // Build query params with from_date and to_date const params = new URLSearchParams() if (fromDate) params.append('from_date', fromDate) if (toDate) params.append('to_date', toDate) const response = await fetch(`/forecasting/api/sync/resos-bookings/sync?${params.toString()}`, { method: 'POST', }) const data = await response.json() if (response.ok) { setSyncStatus('success') setSyncMessage(data.message || 'Sync triggered successfully') refetchStatus() } else { setSyncStatus('error') setSyncMessage(data.detail || 'Failed to trigger sync') } } catch { setSyncStatus('error') setSyncMessage('Failed to trigger sync') } setTimeout(() => { setSyncStatus('idle') setSyncMessage('') }, 5000) } if (configLoading) { return
Loading sync configuration...
} return (

Sync Configuration

Configure automatic synchronization or trigger manual syncs of booking data from Resos.

Automatic Sync

{autoSyncEnabled && ( )}
{saveMessage && (
{saveMessage}
)}

Manual Sync

{syncMessage && (
{syncMessage}
)} {lastSyncStatus && lastSyncStatus.last_sync && (

Last Sync Status

Time: {lastSyncStatus.last_sync.completed_at ? new Date(lastSyncStatus.last_sync.completed_at).toLocaleString() : lastSyncStatus.last_sync.started_at ? new Date(lastSyncStatus.last_sync.started_at).toLocaleString() : 'N/A'}
{lastSyncStatus.last_sync.status && (
Status:{' '} {lastSyncStatus.last_sync.status}
)} {lastSyncStatus.last_sync.error_message && (
Message: {lastSyncStatus.last_sync.error_message}
)}
)}
) } // ============================================ // SPECIAL DATES PAGE // ============================================ interface SpecialDate { id: number name: string pattern_type: 'fixed' | 'nth_weekday' | 'relative_to_date' fixed_month: number | null fixed_day: number | null nth_week: number | null weekday: number | null month: number | null relative_to_month: number | null relative_to_day: number | null relative_weekday: number | null relative_direction: string | null duration_days: number is_recurring: boolean one_off_year: number | null is_active: boolean created_at: string } interface ResolvedDate { name: string date: string day_of_week: string } const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const NTH_OPTIONS = [ { value: 1, label: 'First' }, { value: 2, label: 'Second' }, { value: 3, label: 'Third' }, { value: 4, label: 'Fourth' }, { value: -1, label: 'Last' }, ] const SpecialDatesPage: React.FC = () => { const queryClient = useQueryClient() const [showForm, setShowForm] = useState(false) const [editingDate, setEditingDate] = useState(null) const [previewYear, setPreviewYear] = useState(new Date().getFullYear()) // Form state const [formData, setFormData] = useState({ name: '', pattern_type: 'fixed' as 'fixed' | 'nth_weekday' | 'relative_to_date', fixed_month: 1, fixed_day: 1, nth_week: 1, weekday: 0, month: 1, relative_to_month: 12, relative_to_day: 25, relative_weekday: 4, relative_direction: 'before', duration_days: 1, is_recurring: true, one_off_year: new Date().getFullYear(), is_active: true, }) // Fetch special dates const { data: specialDates, isLoading } = useQuery({ queryKey: ['special-dates'], queryFn: async () => { const response = await fetch('/forecasting/api/settings/special-dates') if (!response.ok) return [] return response.json() }, }) // Fetch preview for year const { data: previewDates } = useQuery({ queryKey: ['special-dates-preview', previewYear], queryFn: async () => { const response = await fetch(`/forecasting/api/settings/special-dates/preview?year=${previewYear}`) if (!response.ok) return [] return response.json() }, }) // Create mutation const createMutation = useMutation({ mutationFn: async (data: typeof formData) => { const response = await fetch('/forecasting/api/settings/special-dates', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data) }) if (!response.ok) throw new Error('Failed to create') return response.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['special-dates'] }) queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) setShowForm(false) resetForm() } }) // Update mutation const updateMutation = useMutation({ mutationFn: async ({ id, data }: { id: number, data: typeof formData }) => { const response = await fetch(`/forecasting/api/settings/special-dates/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data) }) if (!response.ok) throw new Error('Failed to update') return response.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['special-dates'] }) queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) setShowForm(false) setEditingDate(null) resetForm() } }) // Delete mutation const deleteMutation = useMutation({ mutationFn: async (id: number) => { const response = await fetch(`/forecasting/api/settings/special-dates/${id}`, { method: 'DELETE', }) if (!response.ok) throw new Error('Failed to delete') }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['special-dates'] }) queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) } }) // Seed defaults mutation const seedMutation = useMutation({ mutationFn: async () => { const response = await fetch('/forecasting/api/settings/special-dates/seed-defaults', { method: 'POST', }) if (!response.ok) throw new Error('Failed to seed') return response.json() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['special-dates'] }) queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) } }) const resetForm = () => { setFormData({ name: '', pattern_type: 'fixed', fixed_month: 1, fixed_day: 1, nth_week: 1, weekday: 0, month: 1, relative_to_month: 12, relative_to_day: 25, relative_weekday: 4, relative_direction: 'before', duration_days: 1, is_recurring: true, one_off_year: new Date().getFullYear(), is_active: true, }) } const handleEdit = (sd: SpecialDate) => { setEditingDate(sd) setFormData({ name: sd.name, pattern_type: sd.pattern_type, fixed_month: sd.fixed_month || 1, fixed_day: sd.fixed_day || 1, nth_week: sd.nth_week || 1, weekday: sd.weekday || 0, month: sd.month || 1, relative_to_month: sd.relative_to_month || 12, relative_to_day: sd.relative_to_day || 25, relative_weekday: sd.relative_weekday || 4, relative_direction: sd.relative_direction || 'before', duration_days: sd.duration_days || 1, is_recurring: sd.is_recurring, one_off_year: sd.one_off_year || new Date().getFullYear(), is_active: sd.is_active, }) setShowForm(true) } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (editingDate) { updateMutation.mutate({ id: editingDate.id, data: formData }) } else { createMutation.mutate(formData) } } const getPatternDescription = (sd: SpecialDate): string => { if (sd.pattern_type === 'fixed') { return `${MONTHS[(sd.fixed_month || 1) - 1]} ${sd.fixed_day}` } else if (sd.pattern_type === 'nth_weekday') { const nth = NTH_OPTIONS.find(o => o.value === sd.nth_week)?.label || '' return `${nth} ${WEEKDAYS[sd.weekday || 0]} of ${MONTHS[(sd.month || 1) - 1]}` } else { return `${WEEKDAYS[sd.relative_weekday || 0]} ${sd.relative_direction} ${MONTHS[(sd.relative_to_month || 1) - 1]} ${sd.relative_to_day}` } } return (

Special Dates

Configure custom holidays and events for Prophet forecasting

{(!specialDates || specialDates.length === 0) && ( )}
{/* Form Modal */} {showForm && (

{editingDate ? 'Edit Special Date' : 'Add Special Date'}

setFormData({ ...formData, name: e.target.value })} style={styles.input} placeholder="e.g., Valentine's Day" required />
{/* Fixed Date Fields */} {formData.pattern_type === 'fixed' && ( <>
setFormData({ ...formData, fixed_day: parseInt(e.target.value) })} style={styles.input} />
)} {/* Nth Weekday Fields */} {formData.pattern_type === 'nth_weekday' && ( <>
)} {/* Relative to Date Fields */} {formData.pattern_type === 'relative_to_date' && ( <>
setFormData({ ...formData, relative_to_day: parseInt(e.target.value) })} style={styles.input} />
)} {/* Common Fields */}
setFormData({ ...formData, duration_days: parseInt(e.target.value) })} style={styles.input} />
{!formData.is_recurring && (
setFormData({ ...formData, one_off_year: parseInt(e.target.value) })} style={styles.input} />
)}
)} {/* Existing Special Dates List */}
{isLoading ? ( ) : specialDates && specialDates.length > 0 ? ( specialDates.map((sd) => ( )) ) : ( )}
Name Pattern Duration Recurrence Status Actions
Loading...
{sd.name} {getPatternDescription(sd)} {sd.duration_days} day{sd.duration_days > 1 ? 's' : ''} {sd.is_recurring ? 'Every Year' : `${sd.one_off_year} only`} {sd.is_active ? 'Active' : 'Inactive'}
No special dates configured. Click "Seed Defaults" to add common dates.
{/* Preview Section */}

Preview

{previewDates && previewDates.length > 0 ? ( previewDates.map((pd, i) => (
{pd.name}
{pd.day_of_week} {pd.date}
)) ) : (

No dates to preview

)}
) } // ============================================ // DATABASE PAGE // ============================================ const DatabasePage: React.FC = () => { return (

Database Browser

Browse and manage the database using Adminer. Login with: Server: db, Username: forecast, Password: forecast_secret, Database: forecast_data