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 <noreply@anthropic.com>
This commit is contained in:
parent
5f3e52d8a6
commit
a4c1f9bce5
2 changed files with 137 additions and 13 deletions
|
|
@ -186,7 +186,7 @@ html, body, #root {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.refresh-btn {
|
.refresh-btn, .cog-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -194,12 +194,53 @@ html, body, #root {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
transition: color .12s;
|
transition: color .12s, background .12s;
|
||||||
}
|
}
|
||||||
.refresh-btn:hover { color: var(--app-primary); }
|
.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; }
|
.refresh-btn.spinning svg { animation: spin .7s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@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 bars ─────────────────────────────────────────────── */
|
||||||
.filter-section {
|
.filter-section {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
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 type { RoomData, AppConfig, Category, FilterState, StatFilters, SemanticFlowKey, ActivityEntry } from '../types'
|
||||||
import { SEMANTIC_FLOW_MAP } from '../types'
|
import { SEMANTIC_FLOW_MAP } from '../types'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import CategoryGroup from '../components/CategoryGroup'
|
import CategoryGroup from '../components/CategoryGroup'
|
||||||
|
import RoomCard from '../components/RoomCard'
|
||||||
import { FilterBar, StatFilterBar } from '../components/FilterBar'
|
import { FilterBar, StatFilterBar } from '../components/FilterBar'
|
||||||
import ActivityPanel from '../components/ActivityPanel'
|
import ActivityPanel from '../components/ActivityPanel'
|
||||||
import RoomModal from '../components/RoomModal'
|
import RoomModal from '../components/RoomModal'
|
||||||
|
|
@ -41,6 +42,24 @@ export default function Planner() {
|
||||||
const [filters, setFilters] = useState<FilterState>({ categories: {}, flowTypes: {} })
|
const [filters, setFilters] = useState<FilterState>({ categories: {}, flowTypes: {} })
|
||||||
const [statFilters, setStatFilters] = useState<StatFilters>({ newbookTasks: 'off', cleanDirty: 'off' })
|
const [statFilters, setStatFilters] = useState<StatFilters>({ 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 lastEventTime = useRef(new Date().toISOString())
|
||||||
const { toasts, addToast, dismiss: dismissToast } = useToasts(
|
const { toasts, addToast, dismiss: dismissToast } = useToasts(
|
||||||
(config?.checkout_notification_timeout ?? 30) * 1000
|
(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
|
// Apply filters — inclusive/exclusive logic for categories and flows
|
||||||
const visibleRooms = useMemo(() => {
|
const visibleRooms = useMemo(() => {
|
||||||
const inclCats = Object.entries(filters.categories).filter(([, m]) => m === 'inclusive').map(([k]) => k)
|
const inclCats = Object.entries(filters.categories).filter(([, m]) => m === 'inclusive').map(([k]) => k)
|
||||||
|
|
@ -235,6 +262,13 @@ export default function Planner() {
|
||||||
[categories]
|
[categories]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const flatRooms = useMemo(() =>
|
||||||
|
[...visibleRooms].sort((a, b) =>
|
||||||
|
a.site_name.localeCompare(b.site_name, undefined, { numeric: true })
|
||||||
|
),
|
||||||
|
[visibleRooms]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
{/* Date bar */}
|
{/* Date bar */}
|
||||||
|
|
@ -257,18 +291,54 @@ export default function Planner() {
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} strokeWidth={1.75} />
|
<RefreshCw size={16} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`cog-btn${controlsVisible ? ' active' : ''}`}
|
||||||
|
onClick={() => setControlsVisible((v: boolean) => !v)}
|
||||||
|
title="View controls"
|
||||||
|
>
|
||||||
|
<Settings size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* View controls bar */}
|
||||||
|
{controlsVisible && (
|
||||||
|
<div className="view-controls-bar">
|
||||||
|
<div className="control-group">
|
||||||
|
<button className={`control-btn${viewMode === 'flat' ? ' active' : ''}`} onClick={() => setViewMode('flat')}>
|
||||||
|
<List size={13} strokeWidth={1.75} /> Flat List
|
||||||
|
</button>
|
||||||
|
<button className={`control-btn${viewMode === 'grouped' ? ' active' : ''}`} onClick={() => setViewMode('grouped')}>
|
||||||
|
<LayoutList size={13} strokeWidth={1.75} /> Categories
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control-group">
|
||||||
|
<button className={`control-btn${statesFilterVisible ? ' active' : ''}`} onClick={() => setStatesFilterVisible((v: boolean) => !v)}>
|
||||||
|
<Filter size={13} strokeWidth={1.75} /> States Filter
|
||||||
|
</button>
|
||||||
|
<button className={`control-btn${statsFilterVisible ? ' active' : ''}`} onClick={() => setStatsFilterVisible((v: boolean) => !v)}>
|
||||||
|
<BarChart2 size={13} strokeWidth={1.75} /> Stats Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control-group">
|
||||||
|
<button className="control-btn" onClick={resetView}>
|
||||||
|
<RotateCcw size={13} strokeWidth={1.75} /> Reset View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<FilterBar
|
{statesFilterVisible && (
|
||||||
filters={filters}
|
<FilterBar
|
||||||
categories={orderedCategories}
|
filters={filters}
|
||||||
rooms={rooms}
|
categories={orderedCategories}
|
||||||
viewDate={viewDate}
|
rooms={rooms}
|
||||||
onToggleCategory={toggleCategory}
|
viewDate={viewDate}
|
||||||
onToggleFlow={toggleFlow}
|
onToggleCategory={toggleCategory}
|
||||||
/>
|
onToggleFlow={toggleFlow}
|
||||||
<StatFilterBar statFilters={statFilters} onChange={toggleStatFilter} />
|
/>
|
||||||
|
)}
|
||||||
|
{statsFilterVisible && <StatFilterBar statFilters={statFilters} onChange={toggleStatFilter} />}
|
||||||
|
|
||||||
{/* Main area */}
|
{/* Main area */}
|
||||||
<div className="planner-main">
|
<div className="planner-main">
|
||||||
|
|
@ -282,6 +352,20 @@ export default function Planner() {
|
||||||
<div className="error-banner">{error}</div>
|
<div className="error-banner">{error}</div>
|
||||||
) : visibleRooms.length === 0 ? (
|
) : visibleRooms.length === 0 ? (
|
||||||
<div className="empty-state">No rooms match the current filters</div>
|
<div className="empty-state">No rooms match the current filters</div>
|
||||||
|
) : viewMode === 'flat' ? (
|
||||||
|
<div className="planner-body">
|
||||||
|
<div className="category-rooms flat-rooms">
|
||||||
|
{flatRooms.map(room => (
|
||||||
|
<RoomCard
|
||||||
|
key={room.site_id}
|
||||||
|
room={room}
|
||||||
|
viewDate={viewDate}
|
||||||
|
config={config}
|
||||||
|
onClick={setSelectedRoom}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="planner-body">
|
<div className="planner-body">
|
||||||
{orderedCategories.map(cat => {
|
{orderedCategories.map(cat => {
|
||||||
|
|
@ -299,7 +383,6 @@ export default function Planner() {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{/* Rooms without a category */}
|
|
||||||
{groupedRooms.has('__none__') && (
|
{groupedRooms.has('__none__') && (
|
||||||
<CategoryGroup
|
<CategoryGroup
|
||||||
categoryId="__none__"
|
categoryId="__none__"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue