Compare commits

..

No commits in common. "83ac37e4a1de676032dacf957a7b04f24e0a0bc0" and "25d6323aa437b007355faca95881f54a9d598083" have entirely different histories.

3 changed files with 53 additions and 74 deletions

View file

@ -11,10 +11,6 @@ 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,20 +12,30 @@ 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.ok) { if (r.status === 401 || r.status === 403) {
;(window.top ?? window).location.href = '/login' window.location.href = `/portal?redirect=${encodeURIComponent(window.location.href)}`
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(() => { ;(window.top ?? window).location.href = '/login' }) .catch(err => setError(err.message))
}, []) }, [])
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, Layers } from 'lucide-react' import { Plus, RefreshCw, Camera, BedDouble } 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,7 +25,6 @@ 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)
@ -84,17 +83,6 @@ 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() {
@ -151,70 +139,55 @@ 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 renderTask = (t: Task) => { const sid = t.newbook_site_id ? String(t.newbook_site_id) : null
const sid = t.newbook_site_id ? String(t.newbook_site_id) : null const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid)
const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid) const isArriving = roomsView && !!sid && !isOccupied && arrivingSiteIds.has(sid)
const isArriving = roomsView && !!sid && !isOccupied && arrivingSiteIds.has(sid) return (
return ( <div
<div key={t.id}
key={t.id} className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`} style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined}
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined} onClick={() => setOpenTask(t.id)}
onClick={() => setOpenTask(t.id)} >
> <div className="task-card-main">
<div className="task-card-main"> <div className="task-card-title">
<div className="task-card-title"> {t.title}
{t.title} {isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>}
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>} {isArriving && <span className="badge" style={{ background: '#fef3c7', color: '#92400e', border: '1px solid #fcd34d' }}><BedDouble size={10} strokeWidth={1.75} /> Arrival today</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.unusable && <UnusableBadge />} {t.template_id && <span className="badge badge-outline">Recurring</span>}
{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-side"> <div className="task-card-meta">
<PriorityBadge priority={t.priority} /> <span>{t.location_name}</span>
<StatusBadge status={t.status} /> <span>{t.category_name}</span>
<span className="muted" style={{ fontSize: 11.5 }}> {t.asset_name && <span>{t.asset_name}</span>}
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')} <span>{ageLabel(t.created_at)} old</span>
</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>
) <div className="task-card-side">
} <PriorityBadge priority={t.priority} />
<StatusBadge status={t.status} />
if (taskGroups) { <span className="muted" style={{ fontSize: 11.5 }}>
return taskGroups.map(([locationName, groupTasks]) => ( {t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
<div key={locationName}> </span>
<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