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:
commit
1e658b6a48
39 changed files with 3765 additions and 0 deletions
59
frontend/src/components/ActivityPanel.tsx
Normal file
59
frontend/src/components/ActivityPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/AuthGate.tsx
Normal file
51
frontend/src/components/AuthGate.tsx
Normal 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>
|
||||
}
|
||||
61
frontend/src/components/CategoryGroup.tsx
Normal file
61
frontend/src/components/CategoryGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/CheckoutNotification.tsx
Normal file
48
frontend/src/components/CheckoutNotification.tsx
Normal 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 }
|
||||
}
|
||||
133
frontend/src/components/FilterBar.tsx
Normal file
133
frontend/src/components/FilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/Layout.tsx
Normal file
55
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
frontend/src/components/RoomCard.tsx
Normal file
127
frontend/src/components/RoomCard.tsx
Normal 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)
|
||||
276
frontend/src/components/RoomModal.tsx
Normal file
276
frontend/src/components/RoomModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue