Replace collapsible filter accordions with flat horizontal chip bar + add sort chip
Status and filter sections now always visible in one scrollable chip-bar. Sort chip cycles through: Priority high→low, Priority low→high, Age oldest→newest, Age newest→oldest. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
43809b7460
commit
513565ecc8
2 changed files with 73 additions and 90 deletions
|
|
@ -261,28 +261,8 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||||
.badge-unusable { background: var(--danger); }
|
.badge-unusable { background: var(--danger); }
|
||||||
|
|
||||||
/* ── Filter chips ──────────────────────────────────────────── */
|
/* ── Filter chips ──────────────────────────────────────────── */
|
||||||
.filter-section { margin-bottom: 10px; }
|
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||||
.filter-section-header {
|
.chip-divider { width: 1px; height: 20px; background: var(--card-border); flex-shrink: 0; margin: 0 2px; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-mid);
|
|
||||||
transition: border-color .12s;
|
|
||||||
}
|
|
||||||
.filter-section-header:hover { border-color: var(--gold); }
|
|
||||||
.filter-section-label { font-weight: 600; color: var(--text-dark); white-space: nowrap; }
|
|
||||||
.filter-section-summary { flex: 1; color: var(--gold); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.filter-section-summary.muted { color: var(--text-mid); font-weight: 400; }
|
|
||||||
.filter-section-chevron { flex-shrink: 0; transition: transform .18s; }
|
|
||||||
.filter-section-chevron.open { transform: rotate(180deg); }
|
|
||||||
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; align-items: center; }
|
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Plus, RefreshCw, Camera, BedDouble, Layers, ChevronDown } from 'lucide-react'
|
import { Plus, RefreshCw, Camera, BedDouble, Layers, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
|
import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types'
|
||||||
import { STATUS_LABELS, can } from '../types'
|
import { STATUS_LABELS, can } from '../types'
|
||||||
import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api'
|
import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
|
|
@ -27,8 +27,22 @@ export default function Summary() {
|
||||||
const [roomsView, setRoomsView] = useState(false)
|
const [roomsView, setRoomsView] = useState(false)
|
||||||
const [groupByLocation, setGroupByLocation] = useState(false)
|
const [groupByLocation, setGroupByLocation] = useState(false)
|
||||||
|
|
||||||
const [statusOpen, setStatusOpen] = useState(false)
|
type SortMode = 'default' | 'priority_desc' | 'priority_asc' | 'age_asc' | 'age_desc'
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
const [sortMode, setSortMode] = useState<SortMode>('default')
|
||||||
|
|
||||||
|
const SORT_CYCLE: SortMode[] = ['default', 'priority_desc', 'priority_asc', 'age_asc', 'age_desc']
|
||||||
|
const SORT_LABELS: Record<SortMode, string> = {
|
||||||
|
default: 'Sort',
|
||||||
|
priority_desc: 'Priority: High first',
|
||||||
|
priority_asc: 'Priority: Low first',
|
||||||
|
age_asc: 'Age: Oldest first',
|
||||||
|
age_desc: 'Age: Newest first',
|
||||||
|
}
|
||||||
|
const PRIORITY_ORDER: Record<Priority, number> = { urgent: 4, high: 3, medium: 2, low: 1 }
|
||||||
|
|
||||||
|
function cycleSortMode() {
|
||||||
|
setSortMode(prev => SORT_CYCLE[(SORT_CYCLE.indexOf(prev) + 1) % SORT_CYCLE.length])
|
||||||
|
}
|
||||||
|
|
||||||
const [showNew, setShowNew] = useState(false)
|
const [showNew, setShowNew] = useState(false)
|
||||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||||
|
|
@ -87,16 +101,27 @@ export default function Summary() {
|
||||||
return { total: tasks.length, occupied, arriving, free: tasks.length - occupied - arriving }
|
return { total: tasks.length, occupied, arriving, free: tasks.length - occupied - arriving }
|
||||||
}, [tasks, roomsView, occupiedSiteIds, arrivingSiteIds])
|
}, [tasks, roomsView, occupiedSiteIds, arrivingSiteIds])
|
||||||
|
|
||||||
|
const sortedTasks = useMemo(() => {
|
||||||
|
if (sortMode === 'default') return tasks
|
||||||
|
return [...tasks].sort((a, b) => {
|
||||||
|
if (sortMode === 'priority_desc') return (PRIORITY_ORDER[b.priority] ?? 0) - (PRIORITY_ORDER[a.priority] ?? 0)
|
||||||
|
if (sortMode === 'priority_asc') return (PRIORITY_ORDER[a.priority] ?? 0) - (PRIORITY_ORDER[b.priority] ?? 0)
|
||||||
|
if (sortMode === 'age_asc') return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
|
if (sortMode === 'age_desc') return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}, [tasks, sortMode])
|
||||||
|
|
||||||
const taskGroups = useMemo(() => {
|
const taskGroups = useMemo(() => {
|
||||||
if (!groupByLocation) return null
|
if (!groupByLocation) return null
|
||||||
const groups: Record<string, Task[]> = {}
|
const groups: Record<string, Task[]> = {}
|
||||||
for (const t of tasks) {
|
for (const t of sortedTasks) {
|
||||||
const key = t.location_name || 'Unknown'
|
const key = t.location_name || 'Unknown'
|
||||||
if (!groups[key]) groups[key] = []
|
if (!groups[key]) groups[key] = []
|
||||||
groups[key].push(t)
|
groups[key].push(t)
|
||||||
}
|
}
|
||||||
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b))
|
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b))
|
||||||
}, [tasks, groupByLocation])
|
}, [sortedTasks, groupByLocation])
|
||||||
|
|
||||||
const roomsCategoryExists = !!roomsCat
|
const roomsCategoryExists = !!roomsCat
|
||||||
|
|
||||||
|
|
@ -121,68 +146,46 @@ export default function Summary() {
|
||||||
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
<div className="filter-section">
|
<div className="chip-bar" style={{ marginBottom: 12 }}>
|
||||||
<div className="filter-section-header" onClick={() => setStatusOpen(v => !v)}>
|
<button className={`chip ${statusFilter === null ? 'active' : ''}`} onClick={() => setStatusFilter(null)}>
|
||||||
<span className="filter-section-label">Status</span>
|
All open
|
||||||
<span className={`filter-section-summary${statusFilter === null ? ' muted' : ''}`}>
|
</button>
|
||||||
{statusFilter === null ? 'All open' : `${STATUS_LABELS[statusFilter]}${counts[statusFilter] ? ` (${counts[statusFilter]})` : ''}`}
|
{OPEN_STATUSES.map(s => (
|
||||||
</span>
|
<button key={s} className={`chip ${statusFilter === s ? 'active' : ''}`} onClick={() => setStatusFilter(statusFilter === s ? null : s)}>
|
||||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${statusOpen ? ' open' : ''}`} />
|
{STATUS_LABELS[s]}{counts[s] ? ` (${counts[s]})` : ''}
|
||||||
</div>
|
</button>
|
||||||
{statusOpen && (
|
))}
|
||||||
<div className="chip-bar">
|
<span className="chip-divider" />
|
||||||
<button className={`chip ${statusFilter === null ? 'active' : ''}`} onClick={() => setStatusFilter(null)}>
|
{!roomsView && categories.map(c => (
|
||||||
All open
|
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
|
||||||
</button>
|
{c.name}{categoryCounts[c.id] ? ` (${categoryCounts[c.id]})` : ''}
|
||||||
{OPEN_STATUSES.map(s => (
|
</button>
|
||||||
<button key={s} className={`chip ${statusFilter === s ? 'active' : ''}`} onClick={() => setStatusFilter(statusFilter === s ? null : s)}>
|
))}
|
||||||
{STATUS_LABELS[s]}{counts[s] ? ` (${counts[s]})` : ''}
|
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
|
||||||
</button>
|
Mine
|
||||||
))}
|
</button>
|
||||||
</div>
|
{roomsCategoryExists && (
|
||||||
)}
|
<button
|
||||||
</div>
|
className={`chip ${roomsView ? 'active' : ''}`}
|
||||||
|
title="Show all rooms tasks — greyed tasks have an in-house guest so access may be limited"
|
||||||
<div className="filter-section">
|
onClick={toggleRoomsView}
|
||||||
<div className="filter-section-header" onClick={() => setFiltersOpen(v => !v)}>
|
>
|
||||||
<span className="filter-section-label">Filters</span>
|
<BedDouble size={13} strokeWidth={1.75} />
|
||||||
<span className={`filter-section-summary${!categoryFilter && !mineOnly && !roomsView && !groupByLocation ? ' muted' : ''}`}>
|
{roomsView && roomsCounts
|
||||||
{[
|
? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? ` • ${roomsCounts.arriving} arriving` : ''}${roomsCounts.occupied ? ` • ${roomsCounts.occupied} occupied` : ''})`
|
||||||
!roomsView && categoryFilter ? categories.find(c => c.id === categoryFilter)?.name : null,
|
: 'Rooms'}
|
||||||
roomsView ? 'Rooms view' : null,
|
</button>
|
||||||
mineOnly ? 'Mine only' : null,
|
|
||||||
groupByLocation ? 'Grouped' : null,
|
|
||||||
].filter(Boolean).join(' · ') || 'All categories'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${filtersOpen ? ' open' : ''}`} />
|
|
||||||
</div>
|
|
||||||
{filtersOpen && (
|
|
||||||
<div className="chip-bar">
|
|
||||||
{!roomsView && categories.map(c => (
|
|
||||||
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
|
|
||||||
{c.name}{categoryCounts[c.id] ? ` (${categoryCounts[c.id]})` : ''}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
|
|
||||||
Mine
|
|
||||||
</button>
|
|
||||||
{roomsCategoryExists && (
|
|
||||||
<button
|
|
||||||
className={`chip ${roomsView ? 'active' : ''}`}
|
|
||||||
title="Show all rooms tasks — greyed tasks have an in-house guest so access may be limited"
|
|
||||||
onClick={toggleRoomsView}
|
|
||||||
>
|
|
||||||
<BedDouble size={13} strokeWidth={1.75} />
|
|
||||||
{roomsView && roomsCounts
|
|
||||||
? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? ` • ${roomsCounts.arriving} arriving` : ''}${roomsCounts.occupied ? ` • ${roomsCounts.occupied} occupied` : ''})`
|
|
||||||
: 'Rooms Accessible View'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={`chip ${groupByLocation ? 'active' : ''}`} onClick={() => setGroupByLocation(v => !v)}>
|
|
||||||
<Layers size={13} strokeWidth={1.75} /> Group by location
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<button className={`chip ${groupByLocation ? 'active' : ''}`} onClick={() => setGroupByLocation(v => !v)}>
|
||||||
|
<Layers size={13} strokeWidth={1.75} /> Group
|
||||||
|
</button>
|
||||||
|
<span className="chip-divider" />
|
||||||
|
<button className={`chip ${sortMode !== 'default' ? 'active' : ''}`} onClick={cycleSortMode}>
|
||||||
|
{sortMode === 'default' && <ArrowUpDown size={13} strokeWidth={1.75} />}
|
||||||
|
{(sortMode === 'priority_desc' || sortMode === 'age_asc') && <ArrowDown size={13} strokeWidth={1.75} />}
|
||||||
|
{(sortMode === 'priority_asc' || sortMode === 'age_desc') && <ArrowUp size={13} strokeWidth={1.75} />}
|
||||||
|
{SORT_LABELS[sortMode]}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasks.length === 0 && !loading && (
|
{tasks.length === 0 && !loading && (
|
||||||
|
|
@ -242,7 +245,7 @@ export default function Summary() {
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return tasks.map(renderTask)
|
return sortedTasks.map(renderTask)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{showNew && (
|
{showNew && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue