feat: add room-planner app — 3-day HK room view with NewBook integration

NewBook-connected daily housekeeping planner. Replaces the hotelhubmodule-housekeeping-dailylist
WordPress plugin. LXC 120 · 10.10.10.120:3080 · slug: room-planner.

- 3-day booking window (yesterday/today/tomorrow) fetched live from NewBook
- Task completion ticks back to NewBook; room status patches NewBook directly
- 23px border sliver CSS system for adjacent-day booking status
- 3-state filter cycling (off→inclusive→exclusive) for categories and flow types
- Stat filters for outstanding tasks and clean/dirty status
- Rolling 48h activity log with checkout/checkin/status/tasks events
- newbook_pings event bus for future NewBook poller integration
- Room modal with permission-gated guest/rate/notes, task checkboxes, status buttons
- Placeholder sections for future linen-count and routine-tasks modules
- Settings page: task type colours, twin/extra-bed detection, category exclusions
- Mobile-first layout (sidebar desktop, compact top bar mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 13:07:00 +00:00
commit 1e658b6a48
39 changed files with 3765 additions and 0 deletions

View file

@ -0,0 +1,59 @@
import type { ActivityEntry } from '../types'
interface Props {
entries: ActivityEntry[]
}
const EVENT_LABELS: Record<ActivityEntry['event_type'], string> = {
checkout: 'Checked out',
checkin: 'Checked in',
status_clean: 'Marked clean',
status_dirty: 'Marked dirty',
tasks_complete: 'All tasks done',
}
const EVENT_CLASS: Record<ActivityEntry['event_type'], string> = {
checkout: 'event-checkout',
checkin: 'event-checkin',
status_clean: 'event-clean',
status_dirty: 'event-dirty',
tasks_complete: 'event-tasks',
}
function relativeTime(iso: string) {
const diff = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return new Date(iso).toLocaleDateString()
}
export default function ActivityPanel({ entries }: Props) {
return (
<aside className="activity-panel">
<div className="activity-panel-header">Recent Changes</div>
<div className="activity-list">
{entries.length === 0 ? (
<div style={{ padding: '16px 12px', color: 'var(--text-muted)', fontSize: 12 }}>
No activity yet today
</div>
) : (
entries.map(entry => (
<div key={entry.id} className="activity-item">
<div className="activity-room">{entry.room_id}</div>
<div className={`activity-event ${EVENT_CLASS[entry.event_type]}`}>
{EVENT_LABELS[entry.event_type]}
</div>
{entry.user_name && (
<div className="activity-time">{entry.user_name}</div>
)}
<div className="activity-time">{relativeTime(entry.occurred_at)}</div>
</div>
))
)}
</div>
</aside>
)
}

View file

@ -0,0 +1,51 @@
import { useEffect, useState, createContext, useContext } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=room-planner', { credentials: 'include' })
.then(r => {
if (r.status === 401 || r.status === 403) {
window.location.href = `/portal?redirect=${encodeURIComponent(window.location.href)}`
return null
}
if (!r.ok) throw new Error(`Auth check failed: ${r.status}`)
return r.json()
})
.then(data => { if (data) setUser(data.user) })
.catch(err => setError(err.message))
}, [])
if (error) {
return (
<div style={{ padding: 32, color: '#991b1b', fontFamily: 'sans-serif' }}>
Authentication error: {error}
</div>
)
}
if (!user) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'sans-serif', color: '#6b7280'
}}>
Loading
</div>
)
}
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,61 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { RoomData, AppConfig } from '../types'
import RoomCard from './RoomCard'
interface Props {
categoryId: string
categoryName: string
rooms: RoomData[]
viewDate: string
config: AppConfig | null
onRoomClick: (room: RoomData) => void
}
export default function CategoryGroup({ categoryId, categoryName, rooms, viewDate, config, onRoomClick }: Props) {
const [open, setOpen] = useState(true)
const outstandingTasks = rooms.reduce((n, r) => {
return n + r.tasks.filter(t => {
const d = t.task_when_date || t.task_period_from
const inWindow = !d || (d <= viewDate && (!t.task_period_to || t.task_period_to >= viewDate))
return inWindow && !t.completed_on
}).length
}, 0)
const dirtyCount = rooms.filter(r => (r.site_status || '').toLowerCase() === 'dirty').length
return (
<div className="category-group">
<div className="category-header" onClick={() => setOpen(o => !o)}>
<span className="category-name">{categoryName}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{rooms.length}</span>
{outstandingTasks > 0 && (
<span className="category-badge badge-tasks">{outstandingTasks} tasks</span>
)}
{dirtyCount > 0 && (
<span className="category-badge badge-dirty">{dirtyCount} dirty</span>
)}
<ChevronDown
size={14}
strokeWidth={1.75}
className={`category-chevron ${open ? 'open' : 'closed'}`}
/>
</div>
{open && (
<div className="category-rooms">
{rooms.map(room => (
<RoomCard
key={room.site_id}
room={room}
viewDate={viewDate}
config={config}
onClick={onRoomClick}
/>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
interface Toast {
id: string
roomName: string
message: string
}
interface Props {
toasts: Toast[]
onDismiss: (id: string) => void
}
export type { Toast }
export default function CheckoutNotification({ toasts, onDismiss }: Props) {
if (toasts.length === 0) return null
return (
<div className="checkout-toasts">
{toasts.map(toast => (
<div key={toast.id} className="checkout-toast">
<button className="toast-dismiss" onClick={() => onDismiss(toast.id)}>
<X size={12} strokeWidth={1.75} />
</button>
<div className="toast-room">{toast.roomName}</div>
<div className="toast-msg">{toast.message}</div>
</div>
))}
</div>
)
}
// Hook to auto-expire toasts
export function useToasts(timeoutMs = 30000) {
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = (roomName: string, message: string) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
setToasts(t => [...t, { id, roomName, message }])
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), timeoutMs)
}
const dismiss = (id: string) => setToasts(t => t.filter(x => x.id !== id))
return { toasts, addToast, dismiss }
}

View file

@ -0,0 +1,133 @@
import { useRef } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { FlowType, FilterMode, FilterState, StatFilters, StatFilterMode } from '../types'
import type { RoomData } from '../types'
interface FilterBarProps {
filters: FilterState
categories: Array<{ id: string; name: string }>
rooms: RoomData[]
viewDate: string
onToggleCategory: (id: string) => void
onToggleFlow: (flow: FlowType) => void
}
const FLOW_TYPES: FlowType[] = ['arrive', 'depart', 'stopover', 'back-to-back', 'vacant', 'blocked']
const FLOW_LABELS: Record<FlowType, string> = {
arrive: 'Arriving', depart: 'Departing', stopover: 'Staying',
'back-to-back': 'B2B', vacant: 'Vacant', blocked: 'Blocked',
}
function nextMode(mode: FilterMode): FilterMode {
if (mode === 'off') return 'inclusive'
if (mode === 'inclusive') return 'exclusive'
return 'off'
}
function countByCategory(rooms: RoomData[], catId: string) {
return rooms.filter(r => r.category_id === catId).length
}
function countByFlow(rooms: RoomData[], flow: FlowType) {
return rooms.filter(r => r.flow_type === flow).length
}
export function FilterBar({ filters, categories, rooms, viewDate, onToggleCategory, onToggleFlow }: FilterBarProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const scroll = (dir: 'left' | 'right') => {
scrollRef.current?.scrollBy({ left: dir === 'left' ? -120 : 120, behavior: 'smooth' })
}
return (
<div className="filter-section">
{/* Category filter row */}
<div className="filter-bar">
<button className="filter-arrow left" onClick={() => scroll('left')}>
<ChevronLeft size={14} strokeWidth={1.75} />
</button>
<div className="filter-bar-scroll" ref={scrollRef}>
{categories.map(cat => {
const mode = filters.categories[cat.id] ?? 'off'
const count = countByCategory(rooms, cat.id)
return (
<button
key={cat.id}
className={`filter-chip ${mode}`}
onClick={() => onToggleCategory(cat.id)}
>
{cat.name}
<span className="chip-count">{count}</span>
</button>
)
})}
</div>
<button className="filter-arrow" onClick={() => scroll('right')}>
<ChevronRight size={14} strokeWidth={1.75} />
</button>
</div>
{/* Flow type filter row */}
<div className="filter-bar" style={{ borderTop: '1px solid var(--border)' }}>
<div className="filter-bar-scroll">
{FLOW_TYPES.map(flow => {
const mode = filters.flowTypes[flow] ?? 'off'
const count = countByFlow(rooms, flow)
if (count === 0 && mode === 'off') return null
return (
<button
key={flow}
className={`filter-chip ${mode}`}
onClick={() => onToggleFlow(flow)}
>
{FLOW_LABELS[flow]}
<span className="chip-count">{count}</span>
</button>
)
})}
</div>
</div>
</div>
)
}
interface StatFilterBarProps {
statFilters: StatFilters
onChange: (key: keyof StatFilters) => void
}
function nextStatMode(mode: StatFilterMode): StatFilterMode {
if (mode === 'off') return 'show-only'
if (mode === 'show-only') return 'hide'
return 'off'
}
const STAT_LABELS: Record<keyof StatFilters, [string, string, string]> = {
newbookTasks: ['Tasks', 'Has Tasks', 'No Tasks'],
cleanDirty: ['Clean/Dirty', 'Dirty Only', 'Clean Only'],
}
export function StatFilterBar({ statFilters, onChange }: StatFilterBarProps) {
return (
<div className="filter-section" style={{ borderBottom: 'none', borderTop: '1px solid var(--border)' }}>
<div className="filter-bar">
<div className="filter-bar-scroll">
{(Object.keys(statFilters) as Array<keyof StatFilters>).map(key => {
const mode = statFilters[key]
const [off, showOnly, hide] = STAT_LABELS[key]
const label = mode === 'off' ? off : mode === 'show-only' ? showOnly : hide
return (
<button
key={key}
className={`stat-chip ${mode}`}
onClick={() => onChange(key)}
>
{label}
</button>
)
})}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,55 @@
import { NavLink } from 'react-router-dom'
import { BedDouble, Settings, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const hasCap = (cap: string) => user.caps.includes(`room-planner:${cap}`)
return (
<div className="app-shell">
{/* Desktop sidebar */}
<aside className="sidebar">
<div className="sidebar-logo">
<BedDouble size={18} strokeWidth={1.75} />
Room Planner
</div>
<nav className="sidebar-nav">
<NavLink to="/planner" className={({ isActive }) => isActive ? 'active' : ''}>
<BedDouble {...ICON_PROPS} />
Planner
</NavLink>
{hasCap('settings') && (
<NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''}>
<Settings {...ICON_PROPS} />
Settings
</NavLink>
)}
</nav>
<div className="sidebar-user">{user.name}</div>
</aside>
{/* Mobile top bar */}
<header className="top-bar">
<BedDouble size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Room Planner</span>
<nav className="top-bar-nav">
<NavLink to="/planner" className={({ isActive }) => isActive ? 'active' : ''}>
Planner
</NavLink>
{hasCap('settings') && (
<NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''}>
Settings
</NavLink>
)}
</nav>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

View file

@ -0,0 +1,127 @@
import { memo } from 'react'
import type { RoomData, AppConfig } from '../types'
import {
roomCardColor, bookingStatusColor, FLOW_TYPE_LABEL, sliverColor, spanDataAttrs,
hasOutstandingTasks, isDirty,
} from '../lib/booking-flow'
import { detectTwin } from '../lib/twin-detect'
interface Props {
room: RoomData
viewDate: string
config: AppConfig | null
onClick: (room: RoomData) => void
}
function RoomCard({ room, viewDate, config, onClick }: Props) {
const booking = room.booking
const twinType = detectTwin(booking, config)
const taskDisplay = config?.task_display ?? {}
const isBlocked = room.flow_type === 'blocked'
const stripColor = booking ? bookingStatusColor(booking.booking_status) : 'transparent'
const prevColor = sliverColor(room.previous_status)
const nextColor = sliverColor(room.next_status)
// Tasks relevant for today
const todayTasks = room.tasks.filter(t => {
const d = t.task_when_date || t.task_period_from
return !d || d <= viewDate && (t.task_period_to ? t.task_period_to >= viewDate : d === viewDate)
})
const outstanding = todayTasks.filter(t => !t.completed_on)
const dirty = isDirty(room)
const spanAttrs = spanDataAttrs(room)
const cssVars: Record<string, string> = {}
if (room.spans_previous) cssVars['--prev-color'] = prevColor
if (room.spans_next) cssVars['--next-color'] = nextColor
return (
<div
className={`room-card${isBlocked ? ' blocked-room' : ''}`}
style={cssVars as React.CSSProperties}
{...(spanAttrs as React.HTMLAttributes<HTMLDivElement>)}
onClick={() => onClick(room)}
>
{!isBlocked && (
<div className="card-status-strip" style={{ background: stripColor }} />
)}
<div className="card-inner">
<div className="card-top">
<span className="card-room-name">{room.site_name}</span>
<div className="card-badges">
{twinType === 'twin' && <span className="card-badge badge-twin">Twin</span>}
{twinType === 'extra-bed' && <span className="card-badge badge-extra">+Bed</span>}
{booking?.pax != null && booking.pax > 0 && (
<span className="card-badge badge-pax">{booking.pax}p</span>
)}
{room.departing_time && (
<span className="card-badge badge-depart-time">{room.departing_time.slice(0,5)}</span>
)}
</div>
</div>
{!isBlocked && (
<div className="card-mid">
{booking?.guest_name ? (
<span className="card-guest-name">{booking.guest_name}</span>
) : (
<span className="card-guest-name" style={{ opacity: .4 }}>
{room.flow_type === 'vacant' ? 'Vacant' : '—'}
</span>
)}
{booking?.rate_plan_name && (
<span className="card-rate">{booking.rate_plan_name}</span>
)}
</div>
)}
<div className="card-bottom">
{!isBlocked && (
<span
className="card-flow-label"
style={{
background: stripColor + '22',
color: stripColor === 'transparent' ? 'var(--text-muted)' : stripColor,
}}
>
{FLOW_TYPE_LABEL[room.flow_type]}
</span>
)}
<span className={`card-clean-status ${dirty ? 'dirty-dot' : 'clean-dot'}`}>
{dirty ? 'Dirty' : room.site_status}
</span>
{outstanding.length > 0 && (
<span className="card-badge badge-tasks" style={{ background: '#fef3c7', color: '#92400e' }}>
{outstanding.length} task{outstanding.length > 1 ? 's' : ''}
</span>
)}
</div>
{/* Task dots for show_on_card tasks */}
{todayTasks.length > 0 && (
<div className="card-task-dots">
{todayTasks.map(t => {
const display = taskDisplay[t.task_type_id]
if (!display?.show_on_card) return null
return (
<div
key={t.task_id}
className={`task-dot${t.completed_on ? ' complete' : ''}`}
style={{ background: display.color || '#94a3b8' }}
title={display.label || t.task_description}
/>
)
})}
</div>
)}
</div>
</div>
)
}
export default memo(RoomCard)

View file

@ -0,0 +1,276 @@
import { useState, useCallback } from 'react'
import { X, CheckSquare, Square, Package, ClipboardList } from 'lucide-react'
import type { RoomData, TaskData, AppConfig, User } from '../types'
import { bookingStatusColor, isDirty } from '../lib/booking-flow'
import { detectTwin } from '../lib/twin-detect'
import * as api from '../api'
interface Props {
room: RoomData
viewDate: string
config: AppConfig | null
user: User
onClose: () => void
onTaskToggle: (taskId: string, completed: boolean, siteStatus: string | null) => void
onStatusUpdate: (roomId: string, status: string) => void
}
const ICON = { size: 14, strokeWidth: 1.75 }
function formatTime(dt: string | null | undefined) {
if (!dt) return '—'
const t = dt.slice(11, 16)
return t || dt.slice(0, 10)
}
function formatDate(dt: string | null | undefined) {
if (!dt) return '—'
return dt.slice(0, 10)
}
export default function RoomModal({ room, viewDate, config, user, onClose, onTaskToggle, onStatusUpdate }: Props) {
const booking = room.booking
const departing = room.departing_booking
const [loadingTasks, setLoadingTasks] = useState<Set<string>>(new Set())
const [statusLoading, setStatusLoading] = useState(false)
const [notesTab, setNotesTab] = useState<'booking' | 'departing'>('booking')
const hasCap = (cap: string) => user.caps.includes(`room-planner:${cap}`)
const todayTasks = room.tasks.filter(t => {
const d = t.task_when_date || t.task_period_from
return !d || (d <= viewDate && (!t.task_period_to || t.task_period_to >= viewDate))
})
const isRollover = (t: TaskData) => {
const d = t.task_when_date || t.task_period_from
return !!d && d < viewDate
}
const handleTaskToggle = useCallback(async (task: TaskData) => {
if (!hasCap('complete_tasks')) return
if (loadingTasks.has(task.task_id)) return
setLoadingTasks(prev => new Set([...prev, task.task_id]))
try {
if (task.completed_on) {
await api.uncompleteTask(task.task_id)
onTaskToggle(task.task_id, false, null)
} else {
const result = await api.completeTask(task.task_id, room.site_id, viewDate, booking?.booking_reference_id)
onTaskToggle(task.task_id, true, result.site_status)
}
} catch (err) {
console.error('Task toggle failed:', err)
} finally {
setLoadingTasks(prev => { const s = new Set(prev); s.delete(task.task_id); return s })
}
}, [loadingTasks, booking, room.site_id, viewDate, hasCap])
const handleStatus = useCallback(async (status: string) => {
if (!hasCap('update_status') || statusLoading) return
setStatusLoading(true)
try {
await api.updateStatus(room.site_id, status, viewDate, booking?.booking_reference_id)
onStatusUpdate(room.site_id, status)
} catch (err) {
console.error('Status update failed:', err)
} finally {
setStatusLoading(false)
}
}, [statusLoading, room.site_id, viewDate, booking, hasCap])
const twinType = detectTwin(booking, config)
const stripColor = booking ? bookingStatusColor(booking.booking_status) : 'var(--app-primary)'
const allNotesForTab = notesTab === 'booking'
? (booking?.notes ?? [])
: (departing?.notes ?? [])
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal-sheet">
{/* Header */}
<div className="modal-header">
<div className="modal-header-main">
<div className="modal-room-name">{room.site_name}</div>
<div>
<span
className="modal-status-badge"
style={{ background: stripColor + '22', color: stripColor }}
>
{(booking?.booking_status || room.flow_type).toUpperCase()}
</span>
{twinType && (
<span className="modal-status-badge" style={{ background: '#e0e7ff', color: '#3730a3', marginLeft: 4 }}>
{twinType === 'twin' ? 'TWIN' : twinType === 'extra-bed' ? '+BED' : 'DOUBLE'}
</span>
)}
</div>
</div>
<button className="modal-close" onClick={onClose}><X size={20} strokeWidth={1.75} /></button>
</div>
<div className="modal-body">
{/* Booking info bar */}
{booking && (
<div className="modal-booking-bar">
{hasCap('guest_details') && booking.guest_name && (
<div className="modal-booking-field">
<div className="field-label">Guest</div>
<div className="field-value">{booking.guest_name}</div>
</div>
)}
<div className="modal-booking-field">
<div className="field-label">Arrival</div>
<div className="field-value">{formatDate(booking.booking_arrival)}</div>
</div>
<div className="modal-booking-field">
<div className="field-label">Departure</div>
<div className="field-value">{formatDate(booking.booking_departure)}</div>
</div>
{booking.pax != null && (
<div className="modal-booking-field">
<div className="field-label">Pax</div>
<div className="field-value">{booking.pax}</div>
</div>
)}
{hasCap('rate_details') && booking.rate_plan_name && (
<div className="modal-booking-field">
<div className="field-label">Rate</div>
<div className="field-value">{booking.rate_plan_name}</div>
</div>
)}
{booking.booking_eta && (
<div className="modal-booking-field">
<div className="field-label">ETA</div>
<div className="field-value">{formatTime(booking.booking_eta)}</div>
</div>
)}
{booking.booking_reference_id && (
<div className="modal-booking-field">
<div className="field-label">Ref</div>
<div className="field-value" style={{ fontSize: 11 }}>{booking.booking_reference_id}</div>
</div>
)}
</div>
)}
{/* Departing booking bar */}
{departing && departing.booking_id !== booking?.booking_id && (
<div className="modal-booking-bar" style={{ background: '#faf5ff', borderTop: '1px solid #ede9fe' }}>
<div className="modal-booking-field">
<div className="field-label" style={{ color: '#7c3aed' }}>Departing today</div>
{hasCap('guest_details') && departing.guest_name && (
<div className="field-value">{departing.guest_name}</div>
)}
</div>
{departing.booking_reference_id && (
<div className="modal-booking-field">
<div className="field-label">Ref</div>
<div className="field-value" style={{ fontSize: 11 }}>{departing.booking_reference_id}</div>
</div>
)}
</div>
)}
{/* Room status */}
<div className="modal-section">
<div className="modal-section-title">Room Status</div>
<div className="status-btn-row">
{(['Clean', 'Dirty', 'Inspected'] as const).map(s => (
<button
key={s}
className={`status-btn${
room.site_status === s ? ` active-${s.toLowerCase()}` : ''
}`}
disabled={statusLoading || !hasCap('update_status')}
onClick={() => handleStatus(s)}
>
{s}
</button>
))}
</div>
</div>
{/* NewBook Tasks */}
{todayTasks.length > 0 && (
<div className="modal-section">
<div className="modal-section-title">
Tasks ({todayTasks.filter(t => !t.completed_on).length} outstanding)
</div>
<div className="task-list">
{todayTasks.map(task => {
const isLoading = loadingTasks.has(task.task_id)
const done = !!task.completed_on
const rollover = isRollover(task)
return (
<div
key={task.task_id}
className="task-item"
onClick={() => handleTaskToggle(task)}
style={{ cursor: hasCap('complete_tasks') ? 'pointer' : 'default' }}
>
<div className={`task-checkbox${done ? ' checked' : ''}${isLoading ? ' loading' : ''}`}>
{done ? <CheckSquare {...ICON} /> : null}
</div>
<span className={`task-label${done ? ' done' : ''}`}>
{task.task_description}
</span>
{rollover && <span className="task-rollover-tag">Rollover</span>}
</div>
)
})}
</div>
</div>
)}
{/* Notes */}
{((booking?.notes?.length ?? 0) > 0 || (departing?.notes?.length ?? 0) > 0) && (
<div className="modal-section">
<div className="modal-section-title">Notes</div>
{departing && departing.booking_id !== booking?.booking_id && (
<div className="notes-tabs">
<button
className={`notes-tab${notesTab === 'booking' ? ' active' : ''}`}
onClick={() => setNotesTab('booking')}
>
Arriving
</button>
<button
className={`notes-tab${notesTab === 'departing' ? ' active' : ''}`}
onClick={() => setNotesTab('departing')}
>
Departing
</button>
</div>
)}
{allNotesForTab.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No notes</div>
) : (
allNotesForTab.map(note => (
<div key={note.note_id} className="note-item">
{note.note_text}
</div>
))
)}
</div>
)}
{/* Placeholder: Linen Count */}
<div className="placeholder-section">
<Package size={16} strokeWidth={1.75} style={{ marginBottom: 4 }} />
<div>Linen Count coming soon</div>
</div>
{/* Placeholder: Routine Tasks */}
<div className="placeholder-section">
<ClipboardList size={16} strokeWidth={1.75} style={{ marginBottom: 4 }} />
<div>Routine Tasks coming soon</div>
</div>
</div>
</div>
</div>
)
}