Compare commits
2 commits
25d6323aa4
...
83ac37e4a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 83ac37e4a1 | |||
| 965f28186f |
3 changed files with 74 additions and 53 deletions
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue