Compare commits

...

2 commits

Author SHA1 Message Date
83ac37e4a1 Fix expired session showing EmbeddedFallback instead of login page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:44:00 +00:00
965f28186f Add group-by-location toggle to maintenance summary filters
Layers chip in the second chip bar groups tasks by location alphabetically
with section headers. Location name is suppressed from task card meta when
grouping is active to avoid redundancy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:19:53 +00:00
3 changed files with 74 additions and 53 deletions

View file

@ -11,6 +11,10 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
headers: { 'Content-Type': 'application/json', ...opts.headers }, headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts, ...opts,
}) })
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })) const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`) throw new Error(err.error || `Request failed: ${res.status}`)

View file

@ -12,30 +12,20 @@ export function useAuth() {
export default function AuthGate({ children }: { children: React.ReactNode }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
fetch('/api/auth/verify?app=maintenance', { credentials: 'include' }) fetch('/api/auth/verify?app=maintenance', { credentials: 'include' })
.then(r => { .then(r => {
if (r.status === 401 || r.status === 403) { if (!r.ok) {
window.location.href = `/portal?redirect=${encodeURIComponent(window.location.href)}` ;(window.top ?? window).location.href = '/login'
return null return null
} }
if (!r.ok) throw new Error(`Auth check failed: ${r.status}`)
return r.json() return r.json()
}) })
.then(data => { if (data) setUser(data) }) .then(data => { if (data) setUser(data) })
.catch(err => setError(err.message)) .catch(() => { ;(window.top ?? window).location.href = '/login' })
}, []) }, [])
if (error) {
return (
<div style={{ padding: 32, color: 'var(--danger)', fontFamily: 'var(--font)' }}>
Authentication error: {error}
</div>
)
}
if (!user) { if (!user) {
return ( return (
<div style={{ <div style={{

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react' import { Plus, RefreshCw, Camera, BedDouble, Layers } from 'lucide-react'
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types' import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
import { STATUS_LABELS, can } from '../types' import { STATUS_LABELS, can } from '../types'
import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api' import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api'
@ -25,6 +25,7 @@ export default function Summary() {
const [categoryFilter, setCategoryFilter] = useState<number | null>(null) const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
const [mineOnly, setMineOnly] = useState(false) const [mineOnly, setMineOnly] = useState(false)
const [roomsView, setRoomsView] = useState(false) const [roomsView, setRoomsView] = useState(false)
const [groupByLocation, setGroupByLocation] = useState(false)
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const [openTask, setOpenTask] = useState<number | null>(null) const [openTask, setOpenTask] = useState<number | null>(null)
@ -83,6 +84,17 @@ export default function Summary() {
return { total: tasks.length, occupied, arriving, free: tasks.length - occupied - arriving } return { total: tasks.length, occupied, arriving, free: tasks.length - occupied - arriving }
}, [tasks, roomsView, occupiedSiteIds, arrivingSiteIds]) }, [tasks, roomsView, occupiedSiteIds, arrivingSiteIds])
const taskGroups = useMemo(() => {
if (!groupByLocation) return null
const groups: Record<string, Task[]> = {}
for (const t of tasks) {
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))
}, [tasks, groupByLocation])
const roomsCategoryExists = !!roomsCat const roomsCategoryExists = !!roomsCat
function toggleRoomsView() { function toggleRoomsView() {
@ -139,55 +151,70 @@ export default function Summary() {
: 'Rooms Accessible View'} : 'Rooms Accessible View'}
</button> </button>
)} )}
<button className={`chip ${groupByLocation ? 'active' : ''}`} onClick={() => setGroupByLocation(v => !v)}>
<Layers size={13} strokeWidth={1.75} /> Group by location
</button>
</div> </div>
{tasks.length === 0 && !loading && ( {tasks.length === 0 && !loading && (
<div className="empty-state">No open tasks match these filters.</div> <div className="empty-state">No open tasks match these filters.</div>
)} )}
{tasks.map(t => { {(() => {
const sid = t.newbook_site_id ? String(t.newbook_site_id) : null const renderTask = (t: Task) => {
const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid) const sid = t.newbook_site_id ? String(t.newbook_site_id) : null
const isArriving = roomsView && !!sid && !isOccupied && arrivingSiteIds.has(sid) const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid)
return ( const isArriving = roomsView && !!sid && !isOccupied && arrivingSiteIds.has(sid)
<div return (
key={t.id} <div
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`} key={t.id}
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined} className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
onClick={() => setOpenTask(t.id)} style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined}
> onClick={() => setOpenTask(t.id)}
<div className="task-card-main"> >
<div className="task-card-title"> <div className="task-card-main">
{t.title} <div className="task-card-title">
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>} {t.title}
{isArriving && <span className="badge" style={{ background: '#fef3c7', color: '#92400e', border: '1px solid #fcd34d' }}><BedDouble size={10} strokeWidth={1.75} /> Arrival today</span>} {isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>}
{t.unusable && <UnusableBadge />} {isArriving && <span className="badge" style={{ background: '#fef3c7', color: '#92400e', border: '1px solid #fcd34d' }}><BedDouble size={10} strokeWidth={1.75} /> Arrival today</span>}
{t.template_id && <span className="badge badge-outline">Recurring</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>
<div className="task-card-meta"> <div className="task-card-side">
<span>{t.location_name}</span> <PriorityBadge priority={t.priority} />
<span>{t.category_name}</span> <StatusBadge status={t.status} />
{t.asset_name && <span>{t.asset_name}</span>} <span className="muted" style={{ fontSize: 11.5 }}>
<span>{ageLabel(t.created_at)} old</span> {t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
{t.due_date && ( </span>
<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> </div>
<div className="task-card-side"> )
<PriorityBadge priority={t.priority} /> }
<StatusBadge status={t.status} />
<span className="muted" style={{ fontSize: 11.5 }}> if (taskGroups) {
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')} return taskGroups.map(([locationName, groupTasks]) => (
</span> <div key={locationName}>
<div className="section-title">{locationName} <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>({groupTasks.length})</span></div>
{groupTasks.map(renderTask)}
</div> </div>
</div> ))
) }
})} return tasks.map(renderTask)
})()}
{showNew && ( {showNew && (
<NewTaskModal <NewTaskModal