Summary: folder tabs, priority filter badges, sort dropdown; add Hold — Booked

- Status filter replaced by folder tabs (All open / Overdue / In progress /
  Temp fix / On hold) with live count badges; tabs, priority and category
  filters now applied client-side so counts always match
- Priority filter badges with counts; sort is now a dropdown list
- New hold_booked status (contractor booked), grouped with in progress, with
  optional booked date stored in hold_until; hold_until cleared when leaving
  the date-bearing holds

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-21 13:25:19 +00:00
parent e3e7e60594
commit d0ff70eb86
5 changed files with 299 additions and 84 deletions

View file

@ -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')}
</span>
{task.due_date && <span>Due {formatDate(task.due_date)}</span>}
{task.hold_until && <span>On hold until {formatDate(task.hold_until)}</span>}
{task.hold_until && task.status === 'hold_scheduled' && <span>On hold until {formatDate(task.hold_until)}</span>}
{task.hold_until && task.status === 'hold_booked' && <span>Booked for {formatDate(task.hold_until)}</span>}
{task.completed_at && <span>Completed by {task.completed_by_name} · {formatDateTime(task.completed_at)}</span>}
{showCosts && task.cost != null && <span>Cost £{task.cost}{task.cost_notes ? ` (${task.cost_notes})` : ''}</span>}
</div>
@ -317,10 +322,10 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
</button>
))}
</div>
{canUpdate && (TRANSITIONS[task.status] || []).includes('hold_scheduled') && (
<div className="field-row" style={{ maxWidth: 260 }}>
{canUpdate && ((TRANSITIONS[task.status] || []).includes('hold_scheduled') || (TRANSITIONS[task.status] || []).includes('hold_booked')) && (
<div className="field-row" style={{ maxWidth: 360 }}>
<div className="field">
<label>Hold until (for “Hold — Later Date”)</label>
<label>Date for holds (“Later Date” hold-until — required; “Booked” contractor visit — optional)</label>
<input type="date" value={holdUntil} onChange={e => setHoldUntil(e.target.value)} />
</div>
</div>

View file

@ -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;

View file

@ -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<Priority, number> = { 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<Set<string>>(new Set())
const [arrivingSiteIds, setArrivingSiteIds] = useState<Set<string>>(new Set())
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
const [tab, setTab] = useState<TabKey>('all_open')
const [priorityFilter, setPriorityFilter] = useState<Priority | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number | null>(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<SortMode>('default')
const [sortOpen, setSortOpen] = useState(false)
const sortRef = useRef<HTMLDivElement>(null)
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])
}
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<Record<TaskStatus, number>> = {}
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<TabKey, number> = { 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<Priority, number> = { 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<number, number> = {}
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 && <div className="error-banner">{error}</div>}
<div className="filter-row">
<div className="filter-section">
<div className="filter-section-header" onClick={() => setStatusOpen(v => !v)}>
<span className="filter-section-label">Status</span>
<span className={`filter-section-summary${statusFilter === null ? ' muted' : ''}`}>
{statusFilter === null ? 'All open' : `${STATUS_LABELS[statusFilter]}${counts[statusFilter] ? ` (${counts[statusFilter]})` : ''}`}
</span>
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${statusOpen ? ' open' : ''}`} />
</div>
{statusOpen && (
<div className="chip-bar">
<button className={`chip ${statusFilter === null ? 'active' : ''}`} onClick={() => setStatusFilter(null)}>
All open
<div className="folder-tabs" role="tablist">
{TABS.map(({ key, label }) => (
<button
key={key}
role="tab"
aria-selected={tab === key}
className={`folder-tab${tab === key ? ' active' : ''}${key === 'overdue' && tabCounts.overdue > 0 ? ' alert' : ''}`}
onClick={() => setTab(key)}
>
{label}
<span className="folder-tab-count">{tabCounts[key]}</span>
</button>
))}
</div>
<div className="folder-panel">
<div className="prio-filter-row">
<div className="prio-filter-bar">
{[...PRIORITIES].reverse().map(p => (
<button
key={p}
className={`prio-chip prio-chip-${p}${priorityFilter === p ? ' active' : ''}${priorityCounts[p] === 0 && priorityFilter !== p ? ' empty' : ''}`}
onClick={() => setPriorityFilter(priorityFilter === p ? null : p)}
>
{PRIORITY_LABELS[p]}
<span className="prio-chip-count">{priorityCounts[p]}</span>
</button>
{OPEN_STATUSES.map(s => (
<button key={s} className={`chip ${statusFilter === s ? 'active' : ''}`} onClick={() => setStatusFilter(statusFilter === s ? null : s)}>
{STATUS_LABELS[s]}{counts[s] ? ` (${counts[s]})` : ''}
</button>
))}
</div>
)}
))}
</div>
<div className="sort-menu" ref={sortRef}>
<button className={`chip sort-chip ${sortMode !== 'default' ? 'active' : ''}`} onClick={() => setSortOpen(v => !v)}>
<ArrowUpDown size={13} strokeWidth={1.75} />
{sortMode === 'default' ? 'Sort' : SORT_OPTIONS.find(o => o.key === sortMode)?.label}
<ChevronDown size={13} strokeWidth={1.75} className={`filter-section-chevron${sortOpen ? ' open' : ''}`} />
</button>
{sortOpen && (
<div className="sort-dropdown">
{SORT_OPTIONS.map(o => (
<button
key={o.key}
className={`sort-option${sortMode === o.key ? ' selected' : ''}`}
onClick={() => { setSortMode(o.key); setSortOpen(false) }}
>
<span>{o.label}</span>
{sortMode === o.key && <Check size={13} strokeWidth={1.75} />}
</button>
))}
</div>
)}
</div>
</div>
<div className="filter-section">
@ -240,17 +338,10 @@ export default function Summary() {
</div>
)}
</div>
<button className={`chip sort-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>
{tasks.length === 0 && !loading && (
<div className="empty-state">No open tasks match these filters.</div>
{visibleTasks.length === 0 && !loading && (
<div className="empty-state">No tasks match these filters.</div>
)}
{(() => {
@ -286,11 +377,12 @@ export default function Summary() {
{t.asset_name && <span>{t.asset_name}</span>}
<span>{ageLabel(t.created_at)} old</span>
{t.due_date && (
<span className={new Date(t.due_date) < new Date() ? 'overdue' : ''}>
<span className={isOverdue(t, today) ? 'overdue' : ''}>
due {formatDate(t.due_date)}
</span>
)}
{t.hold_until && <span>held until {formatDate(t.hold_until)}</span>}
{t.hold_until && t.status === 'hold_scheduled' && <span>held until {formatDate(t.hold_until)}</span>}
{t.hold_until && t.status === 'hold_booked' && <span>booked for {formatDate(t.hold_until)}</span>}
{t.photo_count > 0 && <span><Camera size={12} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> {t.photo_count}</span>}
</div>
</div>

View file

@ -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<TaskStatus, string> = {
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<TaskStatus, string> = {
// 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<TaskStatus, TaskStatus[]> = {
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'],
}