A guest checking out today but not yet gone was previously lumped in with ordinary stayovers — fully greyed out, no way to tell "the guest is gone in 20 minutes" apart from "the guest isn't leaving at all". Split that out into its own bucket: Backend: classifyBookingStatus() now also recognises 'depart'/'checkedout' statuses (already checked out — room's free regardless of the date window). fetchRoomOccupancy() returns occupied/departing/arriving; an in-house booking whose departure date is today lands in 'departing' instead of 'occupied'. The ?unoccupied=true task filter and /api/occupancy both updated for the new three-way split. Frontend: departing rooms get a light dim (opacity only, no greyscale) instead of the hard grey-out, plus a purple 'Departing' badge — matching the booked=green/occupied=blue/departing=purple colour language hvac already uses for NewBook room states. Chip label breakdown now reads "X free • Y arriving • Z departing • W occupied". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
333 lines
16 KiB
TypeScript
333 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
import { Plus, RefreshCw, Camera, BedDouble, LogOut, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
|
import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types'
|
|
import { STATUS_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']
|
|
|
|
export default function Summary() {
|
|
const { user } = useAuth()
|
|
const [tasks, setTasks] = useState<Task[]>([])
|
|
const [categories, setCategories] = useState<Category[]>([])
|
|
const [locations, setLocations] = useState<Location[]>([])
|
|
const [config, setConfig] = useState<AppConfig | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set())
|
|
const [departingSiteIds, setDepartingSiteIds] = useState<Set<string>>(new Set())
|
|
const [arrivingSiteIds, setArrivingSiteIds] = useState<Set<string>>(new Set())
|
|
|
|
const [statusFilter, setStatusFilter] = useState<TaskStatus | 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 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 [searchParams, setSearchParams] = useSearchParams()
|
|
const [showNew, setShowNew] = useState(false)
|
|
const [newTaskPreset, setNewTaskPreset] = useState<{ locationId?: number; assetId?: number }>({})
|
|
const [openTask, setOpenTask] = useState<number | null>(null)
|
|
|
|
const roomsCat = useMemo(() => categories.find(c => c.is_rooms) ?? null, [categories])
|
|
|
|
const load = useCallback(() => {
|
|
setLoading(true)
|
|
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),
|
|
assigned_to: mineOnly ? user.email : undefined,
|
|
})
|
|
// When rooms-view is on, also fetch live occupancy to mark occupied tasks
|
|
const occFetch = roomsView ? fetchOccupancy().catch(() => null) : Promise.resolve(null)
|
|
|
|
Promise.all([taskFetch, occFetch])
|
|
.then(([t, occ]) => {
|
|
setTasks(t)
|
|
setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set())
|
|
setDepartingSiteIds(occ ? new Set(occ.departing_site_ids) : new Set())
|
|
setArrivingSiteIds(occ ? new Set(occ.arriving_site_ids) : new Set())
|
|
setError(null)
|
|
})
|
|
.catch(err => setError(err.message))
|
|
.finally(() => setLoading(false))
|
|
}, [statusFilter, categoryFilter, mineOnly, roomsView, roomsCat, user.email])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
useEffect(() => {
|
|
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(() => {})
|
|
fetchConfig().then(setConfig).catch(() => {})
|
|
}, [])
|
|
|
|
// Deep link from the Assets page: ?new_asset=&new_location= opens a prefilled
|
|
// Report fault form instead of the asset page having its own creation form.
|
|
useEffect(() => {
|
|
const newAsset = searchParams.get('new_asset')
|
|
const newLocation = searchParams.get('new_location')
|
|
if (newAsset || newLocation) {
|
|
setNewTaskPreset({
|
|
locationId: newLocation ? parseInt(newLocation) : undefined,
|
|
assetId: newAsset ? parseInt(newAsset) : undefined,
|
|
})
|
|
setShowNew(true)
|
|
setSearchParams(prev => {
|
|
const next = new URLSearchParams(prev)
|
|
next.delete('new_asset'); next.delete('new_location')
|
|
return next
|
|
}, { replace: true })
|
|
}
|
|
// 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
|
|
}, [tasks])
|
|
|
|
const categoryCounts = useMemo(() => {
|
|
const c: Record<number, number> = {}
|
|
for (const t of tasks) c[t.category_id] = (c[t.category_id] || 0) + 1
|
|
return c
|
|
}, [tasks])
|
|
|
|
// 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) {
|
|
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])
|
|
|
|
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(() => {
|
|
if (!groupByLocation) return null
|
|
const groups: Record<string, Task[]> = {}
|
|
for (const t of sortedTasks) {
|
|
const key = t.location_name || 'Unknown'
|
|
if (!groups[key]) groups[key] = []
|
|
groups[key].push(t)
|
|
}
|
|
return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b))
|
|
}, [sortedTasks, groupByLocation])
|
|
|
|
const roomsCategoryExists = !!roomsCat
|
|
|
|
function toggleRoomsView() {
|
|
setRoomsView(v => !v)
|
|
setCategoryFilter(null) // clear any category chip when entering/leaving rooms-view
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-header">
|
|
<h1>Open maintenance</h1>
|
|
<button className="btn" onClick={load} disabled={loading}>
|
|
<RefreshCw size={14} strokeWidth={1.75} /> Refresh
|
|
</button>
|
|
{can(user, 'report') && (
|
|
<button className="btn btn-primary" onClick={() => { setNewTaskPreset({}); setShowNew(true) }}>
|
|
<Plus size={14} strokeWidth={1.75} /> Report fault
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{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
|
|
</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="filter-section">
|
|
<div className="filter-section-header" onClick={() => setFiltersOpen(v => !v)}>
|
|
<span className="filter-section-label">Filters</span>
|
|
<span className={`filter-section-summary${!categoryFilter && !mineOnly && !roomsView && !groupByLocation ? ' muted' : ''}`}>
|
|
{[
|
|
!roomsView && categoryFilter ? categories.find(c => c.id === categoryFilter)?.name : null,
|
|
roomsView ? 'Rooms view' : null,
|
|
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.departing ? ` • ${roomsCounts.departing} departing` : ''}${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>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{(() => {
|
|
const renderTask = (t: Task) => {
|
|
const sid = t.newbook_site_id ? String(t.newbook_site_id) : null
|
|
const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid)
|
|
const isDeparting = roomsView && !!sid && !isOccupied && departingSiteIds.has(sid)
|
|
const isArriving = roomsView && !!sid && !isOccupied && !isDeparting && arrivingSiteIds.has(sid)
|
|
return (
|
|
<div
|
|
key={t.id}
|
|
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
|
|
// Occupied (stayover) rooms grey out hard — not accessible today. Departing
|
|
// rooms are lightly dimmed only: guest is still in there right now, but the
|
|
// room frees up later today so it's worth flagging rather than hiding.
|
|
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : isDeparting ? { opacity: 0.8 } : undefined}
|
|
onClick={() => setOpenTask(t.id)}
|
|
>
|
|
<div className="task-card-main">
|
|
<div className="task-card-title">
|
|
{t.title}
|
|
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>}
|
|
{isDeparting && <span className="badge" style={{ background: '#ede9fe', color: '#6d28d9', border: '1px solid #c4b5fd' }}><LogOut size={10} strokeWidth={1.75} /> Departing</span>}
|
|
{isArriving && <span className="badge" style={{ background: '#fef3c7', color: '#92400e', border: '1px solid #fcd34d' }}><BedDouble size={10} strokeWidth={1.75} /> Arrival today</span>}
|
|
{t.unusable && <UnusableBadge />}
|
|
{t.template_id && <span className="badge badge-outline">Recurring</span>}
|
|
</div>
|
|
<div className="task-card-meta">
|
|
{!groupByLocation && <span>{t.location_name}</span>}
|
|
<span>{t.category_name}</span>
|
|
{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' : ''}>
|
|
due {formatDate(t.due_date)}
|
|
</span>
|
|
)}
|
|
{t.hold_until && <span>held until {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>
|
|
<div className="task-card-side">
|
|
<PriorityBadge priority={t.priority} />
|
|
<StatusBadge status={t.status} />
|
|
<span className="muted" style={{ fontSize: 11.5 }}>
|
|
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (taskGroups) {
|
|
return taskGroups.map(([locationName, groupTasks]) => (
|
|
<div key={locationName}>
|
|
<div className="section-title">{locationName} <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>({groupTasks.length})</span></div>
|
|
{groupTasks.map(renderTask)}
|
|
</div>
|
|
))
|
|
}
|
|
return sortedTasks.map(renderTask)
|
|
})()}
|
|
|
|
{showNew && (
|
|
<NewTaskModal
|
|
categories={categories}
|
|
locations={locations}
|
|
config={config}
|
|
initialLocationId={newTaskPreset.locationId}
|
|
initialAssetId={newTaskPreset.assetId}
|
|
onClose={() => setShowNew(false)}
|
|
onCreated={load}
|
|
/>
|
|
)}
|
|
{openTask !== null && (
|
|
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|