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:
parent
e3e7e60594
commit
d0ff70eb86
5 changed files with 299 additions and 84 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue