diff --git a/backend/src/lib/task-core.js b/backend/src/lib/task-core.js
index 382de74..d96298a 100644
--- a/backend/src/lib/task-core.js
+++ b/backend/src/lib/task-core.js
@@ -3,14 +3,15 @@ import { notifyAssignment, notifyUrgent } from './mailer.js'
import { notifyAssignmentPush } from './push.js'
export const PRIORITIES = ['low', 'medium', 'high', 'urgent']
-export const STATUSES = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed']
+export const STATUSES = ['submitted', 'in_progress', 'hold_parts', 'hold_booked', 'hold_scheduled', 'temporary_fix', 'fixed']
// Legal state transitions. temporary_fix still counts as open.
export const TRANSITIONS = {
- submitted: ['in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
- in_progress: ['submitted', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
- hold_parts: ['submitted', 'in_progress', 'hold_scheduled', 'temporary_fix', 'fixed'],
- hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'temporary_fix', 'fixed'],
+ submitted: ['in_progress', 'hold_parts', 'hold_booked', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ in_progress: ['submitted', 'hold_parts', 'hold_booked', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ hold_parts: ['submitted', 'in_progress', 'hold_booked', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ hold_booked: ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'hold_booked', 'temporary_fix', 'fixed'],
temporary_fix: ['submitted', 'in_progress', 'fixed'],
fixed: ['submitted'], // reopen only
}
diff --git a/frontend/src/components/TaskModal.tsx b/frontend/src/components/TaskModal.tsx
index ba5261e..abcd731 100644
--- a/frontend/src/components/TaskModal.tsx
+++ b/frontend/src/components/TaskModal.tsx
@@ -144,6 +144,10 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
if (status === 'hold_scheduled') {
if (!holdUntil) { setError('Pick a hold-until date first'); return }
body.hold_until = holdUntil
+ } else if (status === 'hold_booked') {
+ body.hold_until = holdUntil || null // booked date is optional
+ } else {
+ body.hold_until = null // date only means something on the two date-bearing holds
}
await run(() => updateTask(task!.id, body))
}
@@ -196,7 +200,8 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
: (task.assigned_to_name || 'Unassigned')}
{task.due_date && Due {formatDate(task.due_date)}}
- {task.hold_until && On hold until {formatDate(task.hold_until)}}
+ {task.hold_until && task.status === 'hold_scheduled' && On hold until {formatDate(task.hold_until)}}
+ {task.hold_until && task.status === 'hold_booked' && Booked for {formatDate(task.hold_until)}}
{task.completed_at && Completed by {task.completed_by_name} · {formatDateTime(task.completed_at)}}
{showCosts && task.cost != null && Cost £{task.cost}{task.cost_notes ? ` (${task.cost_notes})` : ''}}
@@ -317,10 +322,10 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
))}
- {canUpdate && (TRANSITIONS[task.status] || []).includes('hold_scheduled') && (
-
+ {canUpdate && ((TRANSITIONS[task.status] || []).includes('hold_scheduled') || (TRANSITIONS[task.status] || []).includes('hold_booked')) && (
+
diff --git a/frontend/src/index.css b/frontend/src/index.css
index cde680a..b0a7e3e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -248,7 +248,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.badge-st-submitted { background: var(--st-submitted); }
.badge-st-in_progress { background: var(--st-in-progress); }
-.badge-st-hold_parts, .badge-st-hold_scheduled { background: var(--st-hold); }
+.badge-st-hold_parts, .badge-st-hold_booked, .badge-st-hold_scheduled { background: var(--st-hold); }
.badge-st-temporary_fix { background: var(--st-temporary); }
.badge-st-fixed { background: var(--st-fixed); }
@@ -261,9 +261,123 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.badge-unusable { background: var(--danger); }
/* ── Filter chips ──────────────────────────────────────────── */
-.filter-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px; }
-.filter-row .filter-section { flex: 1; min-width: 0; margin-bottom: 0; }
-.filter-row .sort-chip { flex-shrink: 0; align-self: center; white-space: nowrap; }
+/* Folder tabs — active tab merges into .folder-panel below it */
+.folder-tabs { display: flex; gap: 4px; overflow-x: auto; padding-top: 4px; margin-bottom: -1px; position: relative; z-index: 1; scrollbar-width: none; }
+.folder-tabs::-webkit-scrollbar { display: none; }
+.folder-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ flex-shrink: 0;
+ padding: 8px 14px;
+ border: 1px solid var(--card-border);
+ border-bottom-color: var(--card-border);
+ border-radius: 8px 8px 0 0;
+ background: var(--body-bg);
+ color: var(--text-mid);
+ font-family: var(--font);
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color .12s, background .12s;
+}
+.folder-tab:hover { color: var(--text-dark); background: var(--card-bg); }
+.folder-tab.active {
+ background: var(--card-bg);
+ color: var(--text-dark);
+ border-bottom-color: var(--card-bg);
+ box-shadow: inset 0 2px 0 var(--gold);
+}
+.folder-tab-count {
+ min-width: 20px;
+ padding: 1px 6px;
+ border-radius: 10px;
+ background: var(--card-border);
+ color: var(--text-mid);
+ font-size: 11px;
+ font-weight: 700;
+ text-align: center;
+}
+.folder-tab.active .folder-tab-count { background: var(--gold); color: var(--navy); }
+.folder-tab.alert .folder-tab-count { background: var(--danger); color: #fff; }
+.folder-panel {
+ background: var(--card-bg);
+ border: 1px solid var(--card-border);
+ border-radius: 0 var(--radius) var(--radius) var(--radius);
+ padding: 10px 12px 2px;
+ margin-bottom: 14px;
+}
+
+.prio-filter-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
+.prio-filter-bar { display: flex; gap: 6px; flex-wrap: wrap; }
+.prio-chip {
+ --prio-c: var(--text-mid);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 6px 4px 12px;
+ border-radius: 20px;
+ border: 1px solid var(--prio-c);
+ background: var(--card-bg);
+ color: var(--prio-c);
+ font-family: var(--font);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all .12s;
+}
+.prio-chip-urgent { --prio-c: var(--prio-urgent); }
+.prio-chip-high { --prio-c: var(--prio-high); }
+.prio-chip-medium { --prio-c: var(--prio-medium); }
+.prio-chip-low { --prio-c: var(--prio-low); }
+.prio-chip-count {
+ min-width: 20px;
+ padding: 1px 6px;
+ border-radius: 10px;
+ background: var(--prio-c);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ text-align: center;
+}
+.prio-chip:hover { background: var(--body-bg); }
+.prio-chip.active { background: var(--prio-c); color: #fff; }
+.prio-chip.active .prio-chip-count { background: #fff; color: var(--prio-c); }
+.prio-chip.empty { opacity: .45; }
+
+.sort-menu { position: relative; flex-shrink: 0; }
+.sort-menu .sort-chip { white-space: nowrap; }
+.sort-dropdown {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 4px);
+ min-width: 190px;
+ background: var(--card-bg);
+ border: 1px solid var(--card-border);
+ border-radius: 8px;
+ box-shadow: var(--shadow-md);
+ padding: 4px;
+ z-index: 20;
+}
+.sort-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ width: 100%;
+ padding: 7px 10px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-dark);
+ font-family: var(--font);
+ font-size: 12.5px;
+ text-align: left;
+ cursor: pointer;
+}
+.sort-option:hover { background: var(--body-bg); }
+.sort-option.selected { color: var(--gold); font-weight: 600; }
.filter-section { margin-bottom: 10px; }
.filter-section-header {
display: flex;
diff --git a/frontend/src/pages/Summary.tsx b/frontend/src/pages/Summary.tsx
index d5f26d5..eea8e2d 100644
--- a/frontend/src/pages/Summary.tsx
+++ b/frontend/src/pages/Summary.tsx
@@ -1,15 +1,57 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
-import { Plus, RefreshCw, Camera, BedDouble, LogOut, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
+import { Plus, RefreshCw, Camera, BedDouble, LogOut, Layers, ChevronDown, ArrowUpDown, Check } from 'lucide-react'
import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types'
-import { STATUS_LABELS, can } from '../types'
+import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api'
import { useAuth } from '../components/AuthGate'
import NewTaskModal from '../components/NewTaskModal'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, UnusableBadge, ageLabel, formatDate } from '../components/shared'
-const OPEN_STATUSES: TaskStatus[] = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix']
+const OPEN_STATUSES: TaskStatus[] = ['submitted', 'in_progress', 'hold_parts', 'hold_booked', 'hold_scheduled', 'temporary_fix']
+
+// Actively being dealt with: working on it, waiting on parts, or contractor booked
+const IN_PROGRESS_STATUSES: TaskStatus[] = ['in_progress', 'hold_parts', 'hold_booked']
+
+type TabKey = 'all_open' | 'overdue' | 'in_progress' | 'temp_fix' | 'on_hold'
+
+// Folder tabs over the unresolved statuses. all_open deliberately excludes temp
+// fixes and scheduled holds (they have their own tabs); overdue cuts across all
+// unresolved tasks.
+const TABS: { key: TabKey; label: string }[] = [
+ { key: 'all_open', label: 'All open' },
+ { key: 'overdue', label: 'Overdue' },
+ { key: 'in_progress', label: 'In progress' },
+ { key: 'temp_fix', label: 'Temp fix' },
+ { key: 'on_hold', label: 'On hold' },
+]
+
+// Due dates are DATE columns serialised as ISO strings — compare the calendar
+// day so a task due today isn't overdue until tomorrow.
+function isOverdue(t: Task, today: string): boolean {
+ return !!t.due_date && t.due_date.slice(0, 10) < today
+}
+
+function inTab(t: Task, tab: TabKey, today: string): boolean {
+ switch (tab) {
+ case 'all_open': return t.status === 'submitted' || IN_PROGRESS_STATUSES.includes(t.status)
+ case 'overdue': return isOverdue(t, today)
+ case 'in_progress': return IN_PROGRESS_STATUSES.includes(t.status)
+ case 'temp_fix': return t.status === 'temporary_fix'
+ case 'on_hold': return t.status === 'hold_scheduled'
+ }
+}
+
+type SortMode = 'default' | 'priority_desc' | 'priority_asc' | 'age_asc' | 'age_desc'
+const SORT_OPTIONS: { key: SortMode; label: string }[] = [
+ { key: 'default', label: 'Default' },
+ { key: 'priority_desc', label: 'Priority: High first' },
+ { key: 'priority_asc', label: 'Priority: Low first' },
+ { key: 'age_asc', label: 'Age: Oldest first' },
+ { key: 'age_desc', label: 'Age: Newest first' },
+]
+const PRIORITY_ORDER: Record
= { urgent: 4, high: 3, medium: 2, low: 1 }
export default function Summary() {
const { user } = useAuth()
@@ -23,31 +65,27 @@ export default function Summary() {
const [departingSiteIds, setDepartingSiteIds] = useState>(new Set())
const [arrivingSiteIds, setArrivingSiteIds] = useState>(new Set())
- const [statusFilter, setStatusFilter] = useState(null)
+ const [tab, setTab] = useState('all_open')
+ const [priorityFilter, setPriorityFilter] = useState(null)
const [categoryFilter, setCategoryFilter] = useState(null)
const [mineOnly, setMineOnly] = useState(false)
const [roomsView, setRoomsView] = useState(false)
const [groupByLocation, setGroupByLocation] = useState(false)
- const [statusOpen, setStatusOpen] = useState(false)
const [filtersOpen, setFiltersOpen] = useState(false)
- type SortMode = 'default' | 'priority_desc' | 'priority_asc' | 'age_asc' | 'age_desc'
const [sortMode, setSortMode] = useState('default')
+ const [sortOpen, setSortOpen] = useState(false)
+ const sortRef = useRef(null)
- const SORT_CYCLE: SortMode[] = ['default', 'priority_desc', 'priority_asc', 'age_asc', 'age_desc']
- const SORT_LABELS: Record = {
- 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 = { urgent: 4, high: 3, medium: 2, low: 1 }
-
- function cycleSortMode() {
- setSortMode(prev => SORT_CYCLE[(SORT_CYCLE.indexOf(prev) + 1) % SORT_CYCLE.length])
- }
+ useEffect(() => {
+ if (!sortOpen) return
+ const onDown = (e: MouseEvent) => {
+ if (sortRef.current && !sortRef.current.contains(e.target as Node)) setSortOpen(false)
+ }
+ document.addEventListener('mousedown', onDown)
+ return () => document.removeEventListener('mousedown', onDown)
+ }, [sortOpen])
const [searchParams, setSearchParams] = useSearchParams()
const [showNew, setShowNew] = useState(false)
@@ -58,10 +96,10 @@ export default function Summary() {
const load = useCallback(() => {
setLoading(true)
+ // Always fetch every unresolved task — tabs, priority and category filters are
+ // applied client-side so their badge counts stay live.
const taskFetch = fetchTasks({
- status: statusFilter ?? OPEN_STATUSES.join(','),
- // In rooms-view mode filter to rooms category; otherwise use the active category chip
- category_id: roomsView ? (roomsCat?.id ?? undefined) : (categoryFilter ?? undefined),
+ status: OPEN_STATUSES.join(','),
assigned_to: mineOnly ? user.email : undefined,
})
// When rooms-view is on, also fetch live occupancy to mark occupied tasks
@@ -77,7 +115,7 @@ export default function Summary() {
})
.catch(err => setError(err.message))
.finally(() => setLoading(false))
- }, [statusFilter, categoryFilter, mineOnly, roomsView, roomsCat, user.email])
+ }, [mineOnly, roomsView, user.email])
useEffect(() => { load() }, [load])
useEffect(() => {
@@ -105,42 +143,72 @@ export default function Summary() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
- const counts = useMemo(() => {
- const c: Partial> = {}
- for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1
- return c
+ const today = useMemo(() => {
+ const d = new Date()
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}, [tasks])
+ // Each facet's badge counts apply every *other* active filter, so the numbers
+ // always match what clicking that badge would show.
+ const matchesCategory = useCallback((t: Task) => {
+ if (roomsView) return roomsCat ? t.category_id === roomsCat.id : false
+ return categoryFilter === null || t.category_id === categoryFilter
+ }, [roomsView, roomsCat, categoryFilter])
+ const matchesPriority = useCallback((t: Task) => priorityFilter === null || t.priority === priorityFilter, [priorityFilter])
+
+ const tabCounts = useMemo(() => {
+ const c: Record = { all_open: 0, overdue: 0, in_progress: 0, temp_fix: 0, on_hold: 0 }
+ for (const t of tasks) {
+ if (!matchesCategory(t) || !matchesPriority(t)) continue
+ for (const { key } of TABS) if (inTab(t, key, today)) c[key]++
+ }
+ return c
+ }, [tasks, matchesCategory, matchesPriority, today])
+
+ const priorityCounts = useMemo(() => {
+ const c: Record = { urgent: 0, high: 0, medium: 0, low: 0 }
+ for (const t of tasks) if (inTab(t, tab, today) && matchesCategory(t)) c[t.priority]++
+ return c
+ }, [tasks, tab, matchesCategory, today])
+
const categoryCounts = useMemo(() => {
const c: Record = {}
- for (const t of tasks) c[t.category_id] = (c[t.category_id] || 0) + 1
+ for (const t of tasks) {
+ if (!inTab(t, tab, today) || !matchesPriority(t)) continue
+ c[t.category_id] = (c[t.category_id] || 0) + 1
+ }
return c
- }, [tasks])
+ }, [tasks, tab, matchesPriority, today])
+
+ const visibleTasks = useMemo(
+ () => tasks.filter(t => inTab(t, tab, today) && matchesCategory(t) && matchesPriority(t)),
+ [tasks, tab, today, matchesCategory, matchesPriority],
+ )
// Rooms chip counts: occupied = guest staying, departing = guest checking out
// today (accessible later), arriving = booked today not yet in
const roomsCounts = useMemo(() => {
if (!roomsView) return null
let occupied = 0, departing = 0, arriving = 0
- for (const t of tasks) {
+ for (const t of visibleTasks) {
const sid = t.newbook_site_id ? String(t.newbook_site_id) : null
if (sid && occupiedSiteIds.has(sid)) occupied++
else if (sid && departingSiteIds.has(sid)) departing++
else if (sid && arrivingSiteIds.has(sid)) arriving++
}
- return { total: tasks.length, occupied, departing, arriving, free: tasks.length - occupied - departing - arriving }
- }, [tasks, roomsView, occupiedSiteIds, departingSiteIds, arrivingSiteIds])
+ return { total: visibleTasks.length, occupied, departing, arriving, free: visibleTasks.length - occupied - departing - arriving }
+ }, [visibleTasks, roomsView, occupiedSiteIds, departingSiteIds, arrivingSiteIds])
const sortedTasks = useMemo(() => {
- if (sortMode === 'default') return tasks
- return [...tasks].sort((a, b) => {
+ if (sortMode === 'default') return visibleTasks
+ return [...visibleTasks].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])
+ }, [visibleTasks, sortMode])
const taskGroups = useMemo(() => {
if (!groupByLocation) return null
@@ -176,27 +244,57 @@ export default function Summary() {
{error && {error}
}
-
-
-
setStatusOpen(v => !v)}>
- Status
-
- {statusFilter === null ? 'All open' : `${STATUS_LABELS[statusFilter]}${counts[statusFilter] ? ` (${counts[statusFilter]})` : ''}`}
-
-
-
- {statusOpen && (
-
-
-
-
- {sortMode === 'default' && }
- {(sortMode === 'priority_desc' || sortMode === 'age_asc') && }
- {(sortMode === 'priority_asc' || sortMode === 'age_desc') && }
- {SORT_LABELS[sortMode]}
-
- {tasks.length === 0 && !loading && (
-
No open tasks match these filters.
+ {visibleTasks.length === 0 && !loading && (
+
No tasks match these filters.
)}
{(() => {
@@ -286,11 +377,12 @@ export default function Summary() {
{t.asset_name &&
{t.asset_name}}
{ageLabel(t.created_at)} old
{t.due_date && (
-
+
due {formatDate(t.due_date)}
)}
- {t.hold_until && held until {formatDate(t.hold_until)}}
+ {t.hold_until && t.status === 'hold_scheduled' && held until {formatDate(t.hold_until)}}
+ {t.hold_until && t.status === 'hold_booked' && booked for {formatDate(t.hold_until)}}
{t.photo_count > 0 && {t.photo_count}}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ccd0f38..3960fc0 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -4,6 +4,7 @@ export type TaskStatus =
| 'submitted'
| 'in_progress'
| 'hold_parts'
+ | 'hold_booked'
| 'hold_scheduled'
| 'temporary_fix'
| 'fixed'
@@ -24,6 +25,7 @@ export const STATUS_LABELS: Record = {
submitted: 'Submitted',
in_progress: 'In Progress',
hold_parts: 'Hold — Parts Ordered',
+ hold_booked: 'Hold — Booked',
hold_scheduled: 'Hold — Later Date',
temporary_fix: 'Temporary Fix',
fixed: 'Fixed',
@@ -32,10 +34,11 @@ export const STATUS_LABELS: Record = {
// Legal transitions mirrored from the backend (task-core.js) so the UI only
// offers valid moves; resolve statuses go through the resolve dialog.
export const TRANSITIONS: Record = {
- submitted: ['in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
- in_progress: ['submitted', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
- hold_parts: ['submitted', 'in_progress', 'hold_scheduled', 'temporary_fix', 'fixed'],
- hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'temporary_fix', 'fixed'],
+ submitted: ['in_progress', 'hold_parts', 'hold_booked', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ in_progress: ['submitted', 'hold_parts', 'hold_booked', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ hold_parts: ['submitted', 'in_progress', 'hold_booked', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ hold_booked: ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
+ hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'hold_booked', 'temporary_fix', 'fixed'],
temporary_fix: ['submitted', 'in_progress', 'fixed'],
fixed: ['submitted'],
}