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
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_HOTEL_NAME
|
||||
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="theme-color" content="#2d6a4f" />
|
||||
<title>Room Planner</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/room-planner/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
server {
|
||||
listen 80;
|
||||
|
||||
location /room-planner/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /room-planner/index.html;
|
||||
}
|
||||
|
||||
location /room-planner/api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# SSE support
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location /room-planner/health {
|
||||
proxy_pass http://backend:3001/health;
|
||||
}
|
||||
}
|
||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "hnf-room-planner-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
22
frontend/src/App.tsx
Normal file
22
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import AuthGate from './components/AuthGate'
|
||||
import Layout from './components/Layout'
|
||||
import Planner from './pages/Planner'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter basename="/room-planner">
|
||||
<AuthGate>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/planner" replace />} />
|
||||
<Route path="/planner" element={<Planner />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/planner" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
77
frontend/src/api.ts
Normal file
77
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { RoomsResponse, ActivityEntry, AppConfig, TaskData } from './types'
|
||||
|
||||
const BASE = '/room-planner/api'
|
||||
|
||||
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
...opts,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || `Request failed: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function fetchRooms(date: string): Promise<RoomsResponse> {
|
||||
return request(`/rooms?date=${date}`)
|
||||
}
|
||||
|
||||
export function completeTask(taskId: string, roomId: string, serviceDate: string, bookingRef?: string) {
|
||||
return request<{ ok: boolean; site_status: string | null }>('/tasks/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId, room_id: roomId, service_date: serviceDate, booking_ref: bookingRef }),
|
||||
})
|
||||
}
|
||||
|
||||
export function uncompleteTask(taskId: string) {
|
||||
return request<{ ok: boolean }>('/tasks/uncomplete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateStatus(roomId: string, status: string, serviceDate: string, bookingRef?: string) {
|
||||
return request<{ ok: boolean }>('/status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ room_id: roomId, status, service_date: serviceDate, booking_ref: bookingRef }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
}
|
||||
|
||||
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
|
||||
return request(`/config/${key}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchTaskTypes(): Promise<Array<{ id: string; name: string }>> {
|
||||
return request('/config/task-types')
|
||||
}
|
||||
|
||||
export function fetchActivity(date: string): Promise<ActivityEntry[]> {
|
||||
return request(`/activity?date=${date}`)
|
||||
}
|
||||
|
||||
export function logActivity(entry: {
|
||||
room_id: string
|
||||
event_type: ActivityEntry['event_type']
|
||||
event_data?: Record<string, unknown>
|
||||
service_date?: string
|
||||
booking_ref?: string
|
||||
}): Promise<{ ok: boolean }> {
|
||||
return request('/activity', { method: 'POST', body: JSON.stringify(entry) })
|
||||
}
|
||||
|
||||
export function fetchEvents(since: string): Promise<{
|
||||
pings: Array<{ id: number; booking_ids: string[]; event_types: string[]; detected_at: string }>
|
||||
server_time: string
|
||||
}> {
|
||||
return request(`/events?since=${encodeURIComponent(since)}`)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
903
frontend/src/index.css
Normal file
903
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
/* Stack design system tokens */
|
||||
:root {
|
||||
--navy: #1a1a2e;
|
||||
--gold: #c9a84c;
|
||||
--body-bg: #f4f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--radius: 8px;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.12);
|
||||
|
||||
/* App theme */
|
||||
--app-primary: #2d6a4f;
|
||||
--app-primary-light: #40916c;
|
||||
--app-primary-dark: #1b4332;
|
||||
|
||||
/* Booking status colours */
|
||||
--col-arrived: #3b82f6;
|
||||
--col-confirmed: #10b981;
|
||||
--col-unconfirmed: #f59e0b;
|
||||
--col-departed: #a855f7;
|
||||
--col-blocked: #6b7280;
|
||||
--col-cancelled: #94a3b8;
|
||||
|
||||
/* Planner layout */
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 56px;
|
||||
--card-h: 72px;
|
||||
--sliver-w: 23px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--body-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Scrollbars ─────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────── */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar (desktop) */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: var(--navy);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-logo {
|
||||
padding: 20px 16px 12px;
|
||||
color: var(--gold);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sidebar-logo svg { opacity: .8; }
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.sidebar-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
color: rgba(255,255,255,.65);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar-nav a:hover { background: rgba(255,255,255,.05); color: #fff; }
|
||||
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
|
||||
.sidebar-user {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid rgba(255,255,255,.08);
|
||||
color: rgba(255,255,255,.45);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Mobile top bar */
|
||||
.top-bar {
|
||||
display: none;
|
||||
height: var(--topbar-h);
|
||||
background: var(--navy);
|
||||
color: #fff;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.top-bar-title {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gold);
|
||||
}
|
||||
.top-bar-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.top-bar-nav a {
|
||||
color: rgba(255,255,255,.6);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.top-bar-nav a.active { color: var(--gold); }
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.top-bar { display: flex; }
|
||||
}
|
||||
|
||||
/* ── Date bar ────────────────────────────────────────────────── */
|
||||
.date-bar {
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.date-bar input[type="date"] {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
background: var(--body-bg);
|
||||
}
|
||||
.date-nav-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
transition: background .12s;
|
||||
}
|
||||
.date-nav-btn:hover { background: var(--body-bg); }
|
||||
.date-today-btn {
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.date-label {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: color .12s;
|
||||
}
|
||||
.refresh-btn:hover { color: var(--app-primary); }
|
||||
.refresh-btn.spinning svg { animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Filter bars ─────────────────────────────────────────────── */
|
||||
.filter-section {
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.filter-bar-scroll {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
.filter-bar-scroll::-webkit-scrollbar { display: none; }
|
||||
.filter-arrow {
|
||||
background: linear-gradient(to right, transparent, var(--card-bg) 40%);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0 6px;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-arrow.left { background: linear-gradient(to left, transparent, var(--card-bg) 40%); }
|
||||
|
||||
/* Filter chips */
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--body-bg);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
transition: all .12s;
|
||||
}
|
||||
.filter-chip:hover { border-color: var(--app-primary); color: var(--app-primary); }
|
||||
.filter-chip.inclusive {
|
||||
background: #d1fae5;
|
||||
border-color: var(--col-confirmed);
|
||||
color: #065f46;
|
||||
}
|
||||
.filter-chip.exclusive {
|
||||
background: #fee2e2;
|
||||
border-color: #ef4444;
|
||||
color: #991b1b;
|
||||
}
|
||||
.chip-count {
|
||||
background: rgba(0,0,0,.12);
|
||||
border-radius: 10px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Stat filter chips */
|
||||
.stat-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--body-bg);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
transition: all .12s;
|
||||
}
|
||||
.stat-chip.show-only { background: #fffbeb; border-color: #f59e0b; color: #92400e; }
|
||||
.stat-chip.hide { background: #f3f4f6; border-color: #9ca3af; color: #374151; text-decoration: line-through; }
|
||||
|
||||
/* ── Planner body ────────────────────────────────────────────── */
|
||||
.planner-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Category group ──────────────────────────────────────────── */
|
||||
.category-group {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 5px;
|
||||
background: var(--body-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.category-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.category-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-tasks { background: #fef3c7; color: #92400e; }
|
||||
.badge-dirty { background: #fee2e2; color: #991b1b; }
|
||||
.category-chevron { color: var(--text-muted); transition: transform .15s; }
|
||||
.category-chevron.open { transform: rotate(0deg); }
|
||||
.category-chevron.closed { transform: rotate(-90deg); }
|
||||
.category-rooms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ── Room card ───────────────────────────────────────────────── */
|
||||
.room-card {
|
||||
position: relative;
|
||||
background: var(--card-bg);
|
||||
min-height: var(--card-h);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: box-shadow .12s;
|
||||
padding-left: var(--sliver-w);
|
||||
padding-right: var(--sliver-w);
|
||||
}
|
||||
.room-card:hover { box-shadow: var(--shadow-md); z-index: 1; }
|
||||
|
||||
/* Left sliver (previous day status) */
|
||||
.room-card[data-spans-previous="true"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--sliver-w);
|
||||
background: var(--prev-color, transparent);
|
||||
opacity: .7;
|
||||
}
|
||||
/* Right sliver (next day status) */
|
||||
.room-card[data-spans-next="true"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--sliver-w);
|
||||
background: var(--next-color, transparent);
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
/* Blocked rooms — fill entire card */
|
||||
.room-card.blocked-room {
|
||||
background: var(--col-blocked);
|
||||
}
|
||||
.room-card.blocked-room .card-room-name,
|
||||
.room-card.blocked-room .card-status-dot { color: rgba(255,255,255,.85); }
|
||||
|
||||
/* Status indicator strip at top of card */
|
||||
.card-status-strip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--sliver-w);
|
||||
right: var(--sliver-w);
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
/* Card inner */
|
||||
.card-inner {
|
||||
padding: 7px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
height: 100%;
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.card-room-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.card-badges {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.badge-twin { background: #e0e7ff; color: #3730a3; }
|
||||
.badge-extra { background: #fce7f3; color: #9d174d; }
|
||||
.badge-pax { background: #f3f4f6; color: #374151; }
|
||||
.badge-depart-time { background: #f5f3ff; color: #6d28d9; }
|
||||
|
||||
.card-mid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.card-guest-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-rate {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.card-flow-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-clean-status {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.clean-dot { background: #d1fae5; color: #065f46; }
|
||||
.dirty-dot { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Task dots row */
|
||||
.card-task-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.task-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-dot.complete { opacity: .3; }
|
||||
|
||||
/* ── Room modal ──────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.4);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
animation: fade-in .15s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-overlay {
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-sheet {
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slide-up .2s ease;
|
||||
}
|
||||
@keyframes slide-up { from { transform: translateY(30px); opacity: 0; } }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-sheet {
|
||||
border-radius: 16px;
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-header-main { flex: 1; min-width: 0; }
|
||||
.modal-room-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-status-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
}
|
||||
.modal-close:hover { background: var(--body-bg); }
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Modal booking info bar */
|
||||
.modal-booking-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: var(--body-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.modal-booking-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.field-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.field-value { font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* Modal section */
|
||||
.modal-section {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-section:last-child { border-bottom: none; }
|
||||
.modal-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Tasks in modal */
|
||||
.task-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--body-bg);
|
||||
transition: background .12s;
|
||||
}
|
||||
.task-item:hover { background: #f0fdf4; }
|
||||
.task-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all .12s;
|
||||
}
|
||||
.task-checkbox.checked {
|
||||
background: var(--app-primary);
|
||||
border-color: var(--app-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.task-checkbox.loading { opacity: .5; }
|
||||
.task-label { font-size: 13px; flex: 1; }
|
||||
.task-label.done { text-decoration: line-through; color: var(--text-muted); }
|
||||
.task-rollover-tag {
|
||||
font-size: 9px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* Status update buttons in modal */
|
||||
.status-btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--body-bg);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all .12s;
|
||||
}
|
||||
.status-btn.active-clean { background: #d1fae5; border-color: #10b981; color: #065f46; }
|
||||
.status-btn.active-dirty { background: #fee2e2; border-color: #ef4444; color: #991b1b; }
|
||||
.status-btn.active-inspected { background: #ede9fe; border-color: #8b5cf6; color: #5b21b6; }
|
||||
|
||||
/* Notes tabs */
|
||||
.notes-tabs { display: flex; gap: 4px; margin-bottom: 10px; }
|
||||
.notes-tab {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--body-bg);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.notes-tab.active { background: var(--app-primary); color: #fff; border-color: var(--app-primary); }
|
||||
.note-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--body-bg);
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.note-type-label { font-size: 10px; color: var(--text-muted); margin-bottom: 2px; }
|
||||
|
||||
/* Placeholder sections (linen, routine tasks) */
|
||||
.placeholder-section {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
opacity: .5;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Activity panel ──────────────────────────────────────────── */
|
||||
.activity-panel {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.activity-panel-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.activity-item {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.activity-item:last-child { border-bottom: none; }
|
||||
.activity-room { font-weight: 700; font-size: 13px; }
|
||||
.activity-event { color: var(--text-muted); margin-top: 1px; }
|
||||
.activity-time { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
|
||||
.event-checkout { color: #6d28d9; }
|
||||
.event-checkin { color: #065f46; }
|
||||
.event-clean { color: #0369a1; }
|
||||
.event-dirty { color: #991b1b; }
|
||||
.event-tasks { color: #2d6a4f; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.activity-panel { display: none; }
|
||||
}
|
||||
|
||||
/* ── Planner layout (date-bar + filter + scroll body) ─────────── */
|
||||
.planner-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.planner-rooms-area {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Checkout notification toasts ────────────────────────────── */
|
||||
.checkout-toasts {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.checkout-toast {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--col-departed);
|
||||
border-left: 4px solid var(--col-departed);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 13px;
|
||||
max-width: 280px;
|
||||
pointer-events: auto;
|
||||
animation: toast-in .2s ease;
|
||||
}
|
||||
@keyframes toast-in { from { transform: translateX(20px); opacity: 0; } }
|
||||
.toast-room { font-weight: 700; }
|
||||
.toast-msg { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||
.toast-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
float: right;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Settings page ───────────────────────────────────────────── */
|
||||
.settings-page {
|
||||
padding: 20px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-section-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.settings-section-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-row:last-child { border-bottom: none; }
|
||||
.settings-label { flex: 1; font-size: 13px; }
|
||||
.settings-hint { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.settings-input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--body-bg);
|
||||
}
|
||||
.settings-toggle {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-toggle.on { background: var(--app-primary); }
|
||||
.settings-toggle.off { background: #d1d5db; }
|
||||
.settings-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left .15s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.2);
|
||||
}
|
||||
.settings-toggle.on::after { left: 21px; }
|
||||
.settings-toggle.off::after { left: 3px; }
|
||||
.save-btn {
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background .12s;
|
||||
}
|
||||
.save-btn:hover { background: var(--app-primary-dark); }
|
||||
.save-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* ── Empty / loading states ──────────────────────────────────── */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.loading-spinner svg { animation: spin .7s linear infinite; }
|
||||
|
||||
.error-banner {
|
||||
margin: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--radius);
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
69
frontend/src/lib/booking-flow.ts
Normal file
69
frontend/src/lib/booking-flow.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { RoomData, BookingData, FlowType } from '../types'
|
||||
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
arrived: '#3b82f6',
|
||||
confirmed: '#10b981',
|
||||
unconfirmed: '#f59e0b',
|
||||
departed: '#a855f7',
|
||||
cancelled: '#94a3b8',
|
||||
blocked: '#6b7280',
|
||||
}
|
||||
|
||||
export const FLOW_TYPE_LABEL: Record<FlowType, string> = {
|
||||
arrive: 'Arriving',
|
||||
depart: 'Departing',
|
||||
stopover: 'Staying',
|
||||
'back-to-back': 'B2B',
|
||||
vacant: 'Vacant',
|
||||
blocked: 'Blocked',
|
||||
}
|
||||
|
||||
export function bookingStatusColor(status: string): string {
|
||||
return STATUS_COLORS[(status || '').toLowerCase()] ?? '#94a3b8'
|
||||
}
|
||||
|
||||
// The primary colour for a room card is based on the booking status of the primary booking.
|
||||
// Blocked rooms fill the entire card background.
|
||||
export function roomCardColor(room: RoomData): string {
|
||||
if (room.flow_type === 'blocked') return '#6b7280'
|
||||
if (!room.booking) return 'transparent'
|
||||
return bookingStatusColor(room.booking.booking_status)
|
||||
}
|
||||
|
||||
// True when all tasks for today's date are complete — used for stat filters
|
||||
export function allTasksComplete(room: RoomData, viewDate: string): boolean {
|
||||
const todayTasks = room.tasks.filter(t => {
|
||||
const d = t.task_when_date || t.task_period_from
|
||||
return d === viewDate || (!d && t.task_period_to && t.task_period_to >= viewDate)
|
||||
})
|
||||
if (todayTasks.length === 0) return true
|
||||
return todayTasks.every(t => !!t.completed_on)
|
||||
}
|
||||
|
||||
export function hasOutstandingTasks(room: RoomData, viewDate: string): boolean {
|
||||
return !allTasksComplete(room, viewDate)
|
||||
}
|
||||
|
||||
export function isDirty(room: RoomData): boolean {
|
||||
return (room.site_status || '').toLowerCase() === 'dirty'
|
||||
}
|
||||
|
||||
// Data attributes for border sliver CSS system (23px margin cards)
|
||||
export function spanDataAttrs(room: RoomData): Record<string, string> {
|
||||
const attrs: Record<string, string> = {}
|
||||
if (room.spans_previous) {
|
||||
attrs['data-spans-previous'] = 'true'
|
||||
if (room.previous_status) attrs['data-prev-status'] = room.previous_status
|
||||
}
|
||||
if (room.spans_next) {
|
||||
attrs['data-spans-next'] = 'true'
|
||||
if (room.next_status) attrs['data-next-status'] = room.next_status
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// Sliver color for ::before / ::after pseudo-elements (passed via CSS custom property)
|
||||
export function sliverColor(status: string | null): string {
|
||||
if (!status) return 'transparent'
|
||||
return bookingStatusColor(status)
|
||||
}
|
||||
46
frontend/src/lib/twin-detect.ts
Normal file
46
frontend/src/lib/twin-detect.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { BookingData, AppConfig, TwinType } from '../types'
|
||||
|
||||
const DEFAULT_TWIN_KEYWORDS = ['twin', 'two single', '2 single', 'single beds']
|
||||
const DEFAULT_EXTRA_KEYWORDS = ['extra bed', 'extra cot', 'rollaway', 'fold out']
|
||||
const DEFAULT_EXCLUDES = ['twin room', 'twin suite']
|
||||
|
||||
export function detectTwin(booking: BookingData | null, config: AppConfig | null): TwinType {
|
||||
if (!booking) return null
|
||||
|
||||
const twinCfg = config?.twin_detection
|
||||
const extraCfg = config?.extra_bed_detection
|
||||
|
||||
// 1. Check custom fields first (highest priority)
|
||||
if (twinCfg?.custom_field_ids?.length) {
|
||||
for (const field of booking.custom_fields || []) {
|
||||
if (twinCfg.custom_field_ids.includes(String(field.field_name ?? ''))) {
|
||||
const val = (field.field_value ?? '').toLowerCase()
|
||||
if (val === 'twin' || val === 'yes' || val === 'true' || val === '1') return 'twin'
|
||||
if (val === 'double') return 'double'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan notes text
|
||||
const allText = [
|
||||
...(booking.notes || []).map(n => n.note_text || ''),
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
const excludeTerms = twinCfg?.exclude_keywords ?? DEFAULT_EXCLUDES
|
||||
const twinTerms = twinCfg?.keywords ?? DEFAULT_TWIN_KEYWORDS
|
||||
const extraTerms = extraCfg?.keywords ?? DEFAULT_EXTRA_KEYWORDS
|
||||
|
||||
if (excludeTerms.some(term => allText.includes(term.toLowerCase()))) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (extraCfg?.enabled !== false && extraTerms.some(term => allText.includes(term.toLowerCase()))) {
|
||||
return 'extra-bed'
|
||||
}
|
||||
|
||||
if (twinCfg?.enabled !== false && twinTerms.some(term => allText.includes(term.toLowerCase()))) {
|
||||
return 'twin'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
331
frontend/src/pages/Planner.tsx
Normal file
331
frontend/src/pages/Planner.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import type { RoomData, AppConfig, Category, FilterState, StatFilters, FlowType, ActivityEntry } from '../types'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import CategoryGroup from '../components/CategoryGroup'
|
||||
import { FilterBar, StatFilterBar } from '../components/FilterBar'
|
||||
import ActivityPanel from '../components/ActivityPanel'
|
||||
import RoomModal from '../components/RoomModal'
|
||||
import CheckoutNotification, { useToasts } from '../components/CheckoutNotification'
|
||||
import { hasOutstandingTasks, isDirty } from '../lib/booking-flow'
|
||||
import * as api from '../api'
|
||||
|
||||
const POLL_INTERVAL = 60000 // rooms refresh every 60s
|
||||
const EVENT_INTERVAL = 30000 // event ping every 30s
|
||||
const ACTIVITY_INTERVAL = 60000
|
||||
|
||||
function todayStr() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string) {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
const today = todayStr()
|
||||
if (dateStr === today) return `Today — ${d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}`
|
||||
return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function Planner() {
|
||||
const { user } = useAuth()
|
||||
const [viewDate, setViewDate] = useState(todayStr)
|
||||
const [rooms, setRooms] = useState<RoomData[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [selectedRoom, setSelectedRoom] = useState<RoomData | null>(null)
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>({ categories: {}, flowTypes: {} })
|
||||
const [statFilters, setStatFilters] = useState<StatFilters>({ newbookTasks: 'off', cleanDirty: 'off' })
|
||||
|
||||
const lastEventTime = useRef(new Date().toISOString())
|
||||
const { toasts, addToast, dismiss: dismissToast } = useToasts(
|
||||
(config?.checkout_notification_timeout ?? 30) * 1000
|
||||
)
|
||||
|
||||
const loadRooms = useCallback(async (date: string, silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
else setRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await api.fetchRooms(date)
|
||||
setRooms(data.rooms)
|
||||
setCategories(data.categories)
|
||||
|
||||
// Detect fresh checkouts vs prior load (depart rooms that just became dirty)
|
||||
if (silent && config) {
|
||||
const deptRooms = data.rooms.filter(r => r.flow_type === 'depart')
|
||||
deptRooms.forEach(r => {
|
||||
const prev = rooms.find(p => p.site_id === r.site_id)
|
||||
if (!prev) {
|
||||
addToast(r.site_name, 'Guest has checked out')
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [config, rooms])
|
||||
|
||||
const loadActivity = useCallback(async (date: string) => {
|
||||
try {
|
||||
const data = await api.fetchActivity(date)
|
||||
setActivity(data)
|
||||
} catch { /* non-critical */ }
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.fetchConfig().then(setConfig).catch(() => {}),
|
||||
]).then(() => {
|
||||
loadRooms(viewDate)
|
||||
loadActivity(viewDate)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Reload on date change
|
||||
useEffect(() => {
|
||||
setRooms([])
|
||||
loadRooms(viewDate)
|
||||
loadActivity(viewDate)
|
||||
}, [viewDate])
|
||||
|
||||
// Periodic room poll
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => loadRooms(viewDate, true), POLL_INTERVAL)
|
||||
return () => clearInterval(timer)
|
||||
}, [viewDate, loadRooms])
|
||||
|
||||
// Activity poll
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => loadActivity(viewDate), ACTIVITY_INTERVAL)
|
||||
return () => clearInterval(timer)
|
||||
}, [viewDate, loadActivity])
|
||||
|
||||
// Event pings poll
|
||||
useEffect(() => {
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const { pings, server_time } = await api.fetchEvents(lastEventTime.current)
|
||||
lastEventTime.current = server_time
|
||||
if (pings.length > 0) {
|
||||
loadRooms(viewDate, true)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, EVENT_INTERVAL)
|
||||
return () => clearInterval(timer)
|
||||
}, [viewDate, loadRooms])
|
||||
|
||||
// Date navigation
|
||||
const navigate = (delta: number) => {
|
||||
const d = new Date(viewDate + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + delta)
|
||||
setViewDate(d.toISOString().slice(0, 10))
|
||||
}
|
||||
|
||||
// Filter logic
|
||||
const toggleCategory = useCallback((id: string) => {
|
||||
setFilters(f => {
|
||||
const cur = f.categories[id] ?? 'off'
|
||||
const next = cur === 'off' ? 'inclusive' : cur === 'inclusive' ? 'exclusive' : 'off'
|
||||
return { ...f, categories: { ...f.categories, [id]: next } }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleFlow = useCallback((flow: FlowType) => {
|
||||
setFilters(f => {
|
||||
const cur = f.flowTypes[flow] ?? 'off'
|
||||
const next = cur === 'off' ? 'inclusive' : cur === 'inclusive' ? 'exclusive' : 'off'
|
||||
return { ...f, flowTypes: { ...f.flowTypes, [flow]: next } }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleStatFilter = useCallback((key: keyof StatFilters) => {
|
||||
setStatFilters(f => {
|
||||
const cur = f[key]
|
||||
const next = cur === 'off' ? 'show-only' : cur === 'show-only' ? 'hide' : 'off'
|
||||
return { ...f, [key]: next }
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Apply filters — inclusive/exclusive logic for categories and flows
|
||||
const visibleRooms = useMemo(() => {
|
||||
const inclCats = Object.entries(filters.categories).filter(([, m]) => m === 'inclusive').map(([k]) => k)
|
||||
const exclCats = Object.entries(filters.categories).filter(([, m]) => m === 'exclusive').map(([k]) => k)
|
||||
const inclFlows = Object.entries(filters.flowTypes).filter(([, m]) => m === 'inclusive').map(([k]) => k) as FlowType[]
|
||||
const exclFlows = Object.entries(filters.flowTypes).filter(([, m]) => m === 'exclusive').map(([k]) => k) as FlowType[]
|
||||
|
||||
return rooms.filter(room => {
|
||||
// Category filters: if any inclusive → must be in inclusive set; exclusive → must not be in exclusive set
|
||||
if (inclCats.length && !inclCats.includes(room.category_id)) return false
|
||||
if (exclCats.includes(room.category_id)) return false
|
||||
|
||||
// Flow type filters
|
||||
if (inclFlows.length && !inclFlows.includes(room.flow_type)) return false
|
||||
if (exclFlows.includes(room.flow_type)) return false
|
||||
|
||||
// Stat filters
|
||||
const tasks = statFilters.newbookTasks
|
||||
if (tasks === 'show-only' && !hasOutstandingTasks(room, viewDate)) return false
|
||||
if (tasks === 'hide' && hasOutstandingTasks(room, viewDate)) return false
|
||||
|
||||
const cd = statFilters.cleanDirty
|
||||
if (cd === 'show-only' && !isDirty(room)) return false
|
||||
if (cd === 'hide' && isDirty(room)) return false
|
||||
|
||||
return true
|
||||
})
|
||||
}, [rooms, filters, statFilters, viewDate])
|
||||
|
||||
// Group by category
|
||||
const groupedRooms = useMemo(() => {
|
||||
const map = new Map<string, RoomData[]>()
|
||||
for (const room of visibleRooms) {
|
||||
const key = room.category_id || '__none__'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(room)
|
||||
}
|
||||
return map
|
||||
}, [visibleRooms])
|
||||
|
||||
// Modal handlers
|
||||
const handleTaskToggle = useCallback((taskId: string, completed: boolean, siteStatus: string | null) => {
|
||||
setRooms(prev => prev.map(r => ({
|
||||
...r,
|
||||
site_status: siteStatus && r.site_id === selectedRoom?.site_id ? siteStatus : r.site_status,
|
||||
tasks: r.tasks.map(t =>
|
||||
t.task_id === taskId
|
||||
? { ...t, completed_on: completed ? new Date().toISOString() : null }
|
||||
: t
|
||||
),
|
||||
})))
|
||||
if (selectedRoom) {
|
||||
setSelectedRoom(prev => prev ? {
|
||||
...prev,
|
||||
site_status: siteStatus ?? prev.site_status,
|
||||
tasks: prev.tasks.map(t =>
|
||||
t.task_id === taskId
|
||||
? { ...t, completed_on: completed ? new Date().toISOString() : null }
|
||||
: t
|
||||
),
|
||||
} : null)
|
||||
}
|
||||
}, [selectedRoom])
|
||||
|
||||
const handleStatusUpdate = useCallback((roomId: string, status: string) => {
|
||||
setRooms(prev => prev.map(r => r.site_id === roomId ? { ...r, site_status: status } : r))
|
||||
setSelectedRoom(prev => prev?.site_id === roomId ? { ...prev, site_status: status } : prev)
|
||||
loadActivity(viewDate)
|
||||
}, [viewDate, loadActivity])
|
||||
|
||||
// Render
|
||||
const orderedCategories = useMemo(() =>
|
||||
[...categories].sort((a, b) => a.order - b.order),
|
||||
[categories]
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Date bar */}
|
||||
<div className="date-bar">
|
||||
<button className="date-nav-btn" onClick={() => navigate(-1)}>←</button>
|
||||
<input
|
||||
type="date"
|
||||
value={viewDate}
|
||||
onChange={e => setViewDate(e.target.value)}
|
||||
/>
|
||||
<span className="date-label">{formatDateLabel(viewDate)}</span>
|
||||
{viewDate !== todayStr() && (
|
||||
<button className="date-today-btn" onClick={() => setViewDate(todayStr())}>Today</button>
|
||||
)}
|
||||
<button
|
||||
className={`refresh-btn${refreshing ? ' spinning' : ''}`}
|
||||
onClick={() => loadRooms(viewDate, true)}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
categories={orderedCategories}
|
||||
rooms={rooms}
|
||||
viewDate={viewDate}
|
||||
onToggleCategory={toggleCategory}
|
||||
onToggleFlow={toggleFlow}
|
||||
/>
|
||||
<StatFilterBar statFilters={statFilters} onChange={toggleStatFilter} />
|
||||
|
||||
{/* Main area */}
|
||||
<div className="planner-main">
|
||||
<div className="planner-rooms-area">
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<RefreshCw size={18} strokeWidth={1.75} />
|
||||
Loading rooms…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="error-banner">{error}</div>
|
||||
) : visibleRooms.length === 0 ? (
|
||||
<div className="empty-state">No rooms match the current filters</div>
|
||||
) : (
|
||||
<div className="planner-body">
|
||||
{orderedCategories.map(cat => {
|
||||
const catRooms = groupedRooms.get(cat.id)
|
||||
if (!catRooms?.length) return null
|
||||
return (
|
||||
<CategoryGroup
|
||||
key={cat.id}
|
||||
categoryId={cat.id}
|
||||
categoryName={cat.name}
|
||||
rooms={catRooms.sort((a, b) => a.site_order - b.site_order)}
|
||||
viewDate={viewDate}
|
||||
config={config}
|
||||
onRoomClick={setSelectedRoom}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* Rooms without a category */}
|
||||
{groupedRooms.has('__none__') && (
|
||||
<CategoryGroup
|
||||
categoryId="__none__"
|
||||
categoryName="Uncategorised"
|
||||
rooms={groupedRooms.get('__none__')!.sort((a, b) => a.site_order - b.site_order)}
|
||||
viewDate={viewDate}
|
||||
config={config}
|
||||
onRoomClick={setSelectedRoom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActivityPanel entries={activity} />
|
||||
</div>
|
||||
|
||||
{/* Room modal */}
|
||||
{selectedRoom && (
|
||||
<RoomModal
|
||||
room={selectedRoom}
|
||||
viewDate={viewDate}
|
||||
config={config}
|
||||
user={user}
|
||||
onClose={() => setSelectedRoom(null)}
|
||||
onTaskToggle={handleTaskToggle}
|
||||
onStatusUpdate={handleStatusUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Checkout toasts */}
|
||||
<CheckoutNotification toasts={toasts} onDismiss={dismissToast} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
frontend/src/pages/Settings.tsx
Normal file
244
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import type { AppConfig, TaskDisplayConfig } from '../types'
|
||||
import * as api from '../api'
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth()
|
||||
const hasCap = (cap: string) => user.caps.includes(`room-planner:${cap}`)
|
||||
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [taskTypes, setTaskTypes] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [saved, setSaved] = useState<Record<string, boolean>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.fetchConfig(),
|
||||
hasCap('settings') ? api.fetchTaskTypes() : Promise.resolve([]),
|
||||
]).then(([cfg, types]) => {
|
||||
setConfig(cfg)
|
||||
setTaskTypes(types)
|
||||
}).catch(err => setError(err.message))
|
||||
}, [])
|
||||
|
||||
if (!hasCap('settings')) {
|
||||
return <div className="error-banner">You don't have permission to access settings.</div>
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="loading-spinner">Loading…</div>
|
||||
}
|
||||
|
||||
async function save(key: string, value: unknown) {
|
||||
setSaving(s => ({ ...s, [key]: true }))
|
||||
setError(null)
|
||||
try {
|
||||
await api.updateConfig(key, value)
|
||||
setConfig(c => c ? { ...c, [key]: value } : c)
|
||||
setSaved(s => ({ ...s, [key]: true }))
|
||||
setTimeout(() => setSaved(s => ({ ...s, [key]: false })), 2000)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, [key]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
function TaskTypeRow({ typeId, typeName }: { typeId: string; typeName: string }) {
|
||||
const display = (config?.task_display ?? {})[typeId] ?? { color: '#94a3b8', show_on_card: true }
|
||||
const update = (patch: Partial<typeof display>) => {
|
||||
const updated: TaskDisplayConfig = {
|
||||
...(config?.task_display ?? {}),
|
||||
[typeId]: { ...display, ...patch },
|
||||
}
|
||||
save('task_display', updated)
|
||||
}
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
<div>{typeName}</div>
|
||||
<div className="settings-hint">ID: {typeId}</div>
|
||||
</div>
|
||||
<input
|
||||
type="color"
|
||||
className="settings-input"
|
||||
value={display.color || '#94a3b8'}
|
||||
style={{ width: 40, padding: 2, height: 32 }}
|
||||
onChange={e => update({ color: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
className={`settings-toggle ${display.show_on_card ? 'on' : 'off'}`}
|
||||
onClick={() => update({ show_on_card: !display.show_on_card })}
|
||||
title="Show dot on card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<h2 style={{ marginTop: 0, marginBottom: 20, fontSize: 20, fontWeight: 700 }}>Settings</h2>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{/* General */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">General</div>
|
||||
<div className="settings-section-body">
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
<div>Default checkout time</div>
|
||||
<div className="settings-hint">Used for checkout notification display</div>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
className="settings-input"
|
||||
defaultValue={config.default_checkout_time || '11:00'}
|
||||
onBlur={e => save('default_checkout_time', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
<div>Checkout notification timeout</div>
|
||||
<div className="settings-hint">Seconds before toast auto-dismisses</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="settings-input"
|
||||
min={5}
|
||||
max={300}
|
||||
style={{ width: 70 }}
|
||||
defaultValue={config.checkout_notification_timeout ?? 30}
|
||||
onBlur={e => save('checkout_notification_timeout', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task display */}
|
||||
{taskTypes.length > 0 && (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">Task Display</div>
|
||||
<div className="settings-section-body" style={{ padding: 0 }}>
|
||||
{taskTypes.map(t => (
|
||||
<div key={t.id} style={{ padding: '0 20px' }}>
|
||||
<TaskTypeRow typeId={t.id} typeName={t.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Twin / Extra bed detection */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">Twin Detection</div>
|
||||
<div className="settings-section-body">
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">Enable twin detection</div>
|
||||
<button
|
||||
className={`settings-toggle ${config.twin_detection?.enabled !== false ? 'on' : 'off'}`}
|
||||
onClick={() => save('twin_detection', {
|
||||
...config.twin_detection,
|
||||
enabled: config.twin_detection?.enabled === false,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
<div>Keywords (comma-separated)</div>
|
||||
<div className="settings-hint">Notes text that indicates a twin configuration</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
style={{ width: 240 }}
|
||||
defaultValue={(config.twin_detection?.keywords ?? []).join(', ')}
|
||||
onBlur={e => save('twin_detection', {
|
||||
...config.twin_detection,
|
||||
keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
<div>Exclude keywords (comma-separated)</div>
|
||||
<div className="settings-hint">Override: if present, twin detection is suppressed</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
style={{ width: 240 }}
|
||||
defaultValue={(config.twin_detection?.exclude_keywords ?? []).join(', ')}
|
||||
onBlur={e => save('twin_detection', {
|
||||
...config.twin_detection,
|
||||
exclude_keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">Extra Bed Detection</div>
|
||||
<div className="settings-section-body">
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">Enable extra bed detection</div>
|
||||
<button
|
||||
className={`settings-toggle ${config.extra_bed_detection?.enabled !== false ? 'on' : 'off'}`}
|
||||
onClick={() => save('extra_bed_detection', {
|
||||
...config.extra_bed_detection,
|
||||
enabled: config.extra_bed_detection?.enabled === false,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
<div>Keywords (comma-separated)</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
style={{ width: 240 }}
|
||||
defaultValue={(config.extra_bed_detection?.keywords ?? []).join(', ')}
|
||||
onBlur={e => save('extra_bed_detection', {
|
||||
...config.extra_bed_detection,
|
||||
keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category exclusions */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">Category Exclusions</div>
|
||||
<div className="settings-section-body">
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
<div>Excluded category IDs (comma-separated)</div>
|
||||
<div className="settings-hint">Rooms in these categories show with excluded flag</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
style={{ width: 200 }}
|
||||
defaultValue={(config.excluded_categories ?? []).join(', ')}
|
||||
onBlur={e => save('excluded_categories',
|
||||
e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">Hide excluded categories entirely</div>
|
||||
<button
|
||||
className={`settings-toggle ${config.hide_excluded_categories ? 'on' : 'off'}`}
|
||||
onClick={() => save('hide_excluded_categories', !config.hide_excluded_categories)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
frontend/src/types.ts
Normal file
143
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
export type BookingStatus = 'arrived' | 'confirmed' | 'unconfirmed' | 'departed' | 'cancelled' | 'blocked'
|
||||
|
||||
export type FlowType = 'arrive' | 'depart' | 'stopover' | 'back-to-back' | 'vacant' | 'blocked'
|
||||
|
||||
export type TwinType = 'twin' | 'double' | 'extra-bed' | null
|
||||
|
||||
export type SiteStatus = 'Clean' | 'Dirty' | 'Inspected'
|
||||
|
||||
export interface BookingData {
|
||||
booking_id: string
|
||||
booking_reference_id?: string
|
||||
booking_status: string
|
||||
booking_arrival: string
|
||||
booking_departure: string
|
||||
booking_eta?: string
|
||||
booking_locked?: boolean
|
||||
pax?: number
|
||||
site_id: string
|
||||
custom_fields?: Array<{ field_name: string; field_value: string }>
|
||||
guest_name?: string // only if user has guest_details cap
|
||||
rate_plan_name?: string // only if user has rate_details cap
|
||||
notes?: Array<{ note_id: string; note_type_id: string; note_text: string }>
|
||||
}
|
||||
|
||||
export interface TaskData {
|
||||
task_id: string
|
||||
task_description: string
|
||||
task_type_id: string
|
||||
task_when_date: string | null
|
||||
task_period_from: string | null
|
||||
task_period_to: string | null
|
||||
site_id: string
|
||||
booking_id?: string
|
||||
completed_on: string | null
|
||||
}
|
||||
|
||||
export interface RoomData {
|
||||
site_id: string
|
||||
site_name: string
|
||||
site_status: string
|
||||
category_id: string
|
||||
category_name: string
|
||||
category_order: number
|
||||
site_order: number
|
||||
|
||||
flow_type: FlowType
|
||||
booking: BookingData | null
|
||||
spans_previous: boolean
|
||||
spans_next: boolean
|
||||
|
||||
previous_booking: BookingData | null
|
||||
next_booking: BookingData | null
|
||||
previous_status: string | null
|
||||
next_status: string | null
|
||||
|
||||
departing_booking: BookingData | null
|
||||
departing_time: string | null
|
||||
|
||||
filter_excluded: boolean
|
||||
tasks: TaskData[]
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface RoomsResponse {
|
||||
view_date: string
|
||||
yesterday: string
|
||||
tomorrow: string
|
||||
rooms: RoomData[]
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: number
|
||||
room_id: string
|
||||
event_type: 'checkout' | 'checkin' | 'status_clean' | 'status_dirty' | 'tasks_complete'
|
||||
event_data: Record<string, unknown> | null
|
||||
user_name: string
|
||||
occurred_at: string
|
||||
booking_ref: string | null
|
||||
}
|
||||
|
||||
export type FilterMode = 'off' | 'inclusive' | 'exclusive'
|
||||
|
||||
export interface FilterState {
|
||||
categories: Record<string, FilterMode>
|
||||
flowTypes: Partial<Record<FlowType, FilterMode>>
|
||||
}
|
||||
|
||||
export type StatFilterMode = 'off' | 'show-only' | 'hide'
|
||||
|
||||
export interface StatFilters {
|
||||
newbookTasks: StatFilterMode // off → show-only (has outstanding tasks) → hide
|
||||
cleanDirty: StatFilterMode // off → show-only (dirty) → hide
|
||||
}
|
||||
|
||||
export interface TaskDisplayConfig {
|
||||
[taskTypeId: string]: {
|
||||
color: string
|
||||
label?: string
|
||||
show_on_card: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
task_display: TaskDisplayConfig
|
||||
twin_detection: {
|
||||
enabled: boolean
|
||||
custom_field_ids: string[]
|
||||
keywords: string[]
|
||||
exclude_keywords: string[]
|
||||
}
|
||||
extra_bed_detection: {
|
||||
enabled: boolean
|
||||
keywords: string[]
|
||||
}
|
||||
excluded_categories: string[]
|
||||
hide_excluded_categories: boolean
|
||||
visible_note_types: string[]
|
||||
checkout_notification_timeout: number
|
||||
default_checkout_time: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
apps: string[]
|
||||
caps: string[]
|
||||
}
|
||||
|
||||
export type PlannerCap =
|
||||
| 'view'
|
||||
| 'guest_details'
|
||||
| 'rate_details'
|
||||
| 'view_all_notes'
|
||||
| 'complete_tasks'
|
||||
| 'update_status'
|
||||
| 'settings'
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/room-planner/',
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue