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')) && ( +
- + setHoldUntil(e.target.value)} />
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 && ( -
- + ))} +
+ +
+
+
+ {[...PRIORITIES].reverse().map(p => ( + - {OPEN_STATUSES.map(s => ( - - ))} -
- )} + ))} +
+ +
+ + {sortOpen && ( +
+ {SORT_OPTIONS.map(o => ( + + ))} +
+ )} +
@@ -240,17 +338,10 @@ export default function Summary() {
)}
- -
- {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'], }