From a4c1f9bce585c211efa26fe0cb613e31482fd243 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 4 Jul 2026 13:41:39 +0000 Subject: [PATCH] Room planner: controls bar redesign + semantic filters + flat list view - Cog button in date bar toggles controls bar visibility (persisted to localStorage) - Controls bar: [Flat List][Categories] view toggle | [States Filter][Stats Filter] filter visibility toggles | [Reset View] - Flat list view renders all visible rooms sorted numerically by name, no category headers - States/stats filter bars independently show/hide based on controls bar toggles - All view preferences (controlsVisible, viewMode, statesFilterVisible, statsFilterVisible) persist across page refreshes - Semantic flow filters: 'arriving'/'departing' each match back-to-back rooms too; 'back-to-back' as exact match - Filter chip counts use semantic union logic to match filter behaviour - Guest name extraction tries guests[0].firstname+lastname (NewBook field names) - booking_locked normalised: '0'/'1' strings handled explicitly (JS truthy fix) - Category sort order pulled from settings global_config via internal HTTP endpoint Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/index.css | 45 +++++++++++++- frontend/src/pages/Planner.tsx | 105 +++++++++++++++++++++++++++++---- 2 files changed, 137 insertions(+), 13 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 7466bbe..d91fac9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -186,7 +186,7 @@ html, body, #root { font-weight: 600; font-size: 14px; } -.refresh-btn { +.refresh-btn, .cog-btn { background: none; border: none; cursor: pointer; @@ -194,12 +194,53 @@ html, body, #root { padding: 4px; border-radius: 6px; display: flex; - transition: color .12s; + transition: color .12s, background .12s; } .refresh-btn:hover { color: var(--app-primary); } +.cog-btn:hover { color: var(--app-primary); } +.cog-btn.active { color: var(--app-primary); background: color-mix(in srgb, var(--app-primary) 10%, transparent); } .refresh-btn.spinning svg { animation: spin .7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } +/* ── View controls bar ───────────────────────────────────────── */ +.view-controls-bar { + background: var(--card-bg); + border-bottom: 1px solid var(--border); + padding: 6px 12px; + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + flex-wrap: wrap; +} +.control-group { + display: flex; + gap: 2px; +} +.control-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: none; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + transition: background .12s, color .12s; +} +.control-btn:hover { background: var(--body-bg); color: var(--text-primary); } +.control-btn.active { + background: color-mix(in srgb, var(--app-primary) 12%, transparent); + border-color: var(--app-primary); + color: var(--app-primary); + font-weight: 600; +} +.control-group .control-btn:not(:first-child) { border-left: none; border-radius: 0 6px 6px 0; } +.control-group .control-btn:first-child:not(:last-child) { border-radius: 6px 0 0 6px; } + /* ── Filter bars ─────────────────────────────────────────────── */ .filter-section { background: var(--card-bg); diff --git a/frontend/src/pages/Planner.tsx b/frontend/src/pages/Planner.tsx index 62e6dc8..6c27af6 100644 --- a/frontend/src/pages/Planner.tsx +++ b/frontend/src/pages/Planner.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { RefreshCw } from 'lucide-react' +import { RefreshCw, Settings, List, LayoutList, Filter, BarChart2, RotateCcw } from 'lucide-react' import type { RoomData, AppConfig, Category, FilterState, StatFilters, SemanticFlowKey, ActivityEntry } from '../types' import { SEMANTIC_FLOW_MAP } from '../types' import { useAuth } from '../components/AuthGate' import CategoryGroup from '../components/CategoryGroup' +import RoomCard from '../components/RoomCard' import { FilterBar, StatFilterBar } from '../components/FilterBar' import ActivityPanel from '../components/ActivityPanel' import RoomModal from '../components/RoomModal' @@ -41,6 +42,24 @@ export default function Planner() { const [filters, setFilters] = useState({ categories: {}, flowTypes: {} }) const [statFilters, setStatFilters] = useState({ newbookTasks: 'off', cleanDirty: 'off' }) + // View preferences — persisted to localStorage + const [controlsVisible, setControlsVisible] = useState(() => + JSON.parse(localStorage.getItem('rp-controls-visible') ?? 'true') + ) + const [viewMode, setViewMode] = useState<'flat' | 'grouped'>(() => + (localStorage.getItem('rp-view-mode') ?? 'grouped') as 'flat' | 'grouped' + ) + const [statesFilterVisible, setStatesFilterVisible] = useState(() => + JSON.parse(localStorage.getItem('rp-states-visible') ?? 'true') + ) + const [statsFilterVisible, setStatsFilterVisible] = useState(() => + JSON.parse(localStorage.getItem('rp-stats-visible') ?? 'true') + ) + useEffect(() => { localStorage.setItem('rp-controls-visible', JSON.stringify(controlsVisible)) }, [controlsVisible]) + useEffect(() => { localStorage.setItem('rp-view-mode', viewMode) }, [viewMode]) + useEffect(() => { localStorage.setItem('rp-states-visible', JSON.stringify(statesFilterVisible)) }, [statesFilterVisible]) + useEffect(() => { localStorage.setItem('rp-stats-visible', JSON.stringify(statsFilterVisible)) }, [statsFilterVisible]) + const lastEventTime = useRef(new Date().toISOString()) const { toasts, addToast, dismiss: dismissToast } = useToasts( (config?.checkout_notification_timeout ?? 30) * 1000 @@ -155,6 +174,14 @@ export default function Planner() { }) }, []) + const resetView = useCallback(() => { + setFilters({ categories: {}, flowTypes: {} }) + setStatFilters({ newbookTasks: 'off', cleanDirty: 'off' }) + setViewMode('grouped') + setStatesFilterVisible(true) + setStatsFilterVisible(true) + }, []) + // 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) @@ -235,6 +262,13 @@ export default function Planner() { [categories] ) + const flatRooms = useMemo(() => + [...visibleRooms].sort((a, b) => + a.site_name.localeCompare(b.site_name, undefined, { numeric: true }) + ), + [visibleRooms] + ) + return (
{/* Date bar */} @@ -257,18 +291,54 @@ export default function Planner() { > +
+ {/* View controls bar */} + {controlsVisible && ( +
+
+ + +
+
+ + +
+
+ +
+
+ )} + {/* Filters */} - - + {statesFilterVisible && ( + + )} + {statsFilterVisible && } {/* Main area */}
@@ -282,6 +352,20 @@ export default function Planner() {
{error}
) : visibleRooms.length === 0 ? (
No rooms match the current filters
+ ) : viewMode === 'flat' ? ( +
+
+ {flatRooms.map(room => ( + + ))} +
+
) : (
{orderedCategories.map(cat => { @@ -299,7 +383,6 @@ export default function Planner() { /> ) })} - {/* Rooms without a category */} {groupedRooms.has('__none__') && (