Maintenance log book app — initial scaffold

Multi-department fault log: NewBook-synced room locations + manual
locations with categories, six-state task flow (submitted/in progress/
hold-parts/hold-later/temporary fix/fixed), photos per stage, priorities
with unusable flag and per-task NewBook out-of-order push, costs on
resolve, comment/audit thread, recurring task templates with
note-to-template carryover, asset register, contractor register with
document attachments, staff/contractor allocation, occupancy-aware
summary filter, searchable history with CSV export, email notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 21:28:57 +00:00
commit 6ca395097e
47 changed files with 6727 additions and 0 deletions

View file

@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react'
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
import { STATUS_LABELS, can } from '../types'
import { fetchTasks, fetchLocations, fetchConfig } 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 [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
const [mineOnly, setMineOnly] = useState(false)
const [unoccupiedOnly, setUnoccupiedOnly] = useState(false)
const [showNew, setShowNew] = useState(false)
const [openTask, setOpenTask] = useState<number | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchTasks({
status: statusFilter ?? OPEN_STATUSES.join(','),
category_id: categoryFilter ?? undefined,
assigned_to: mineOnly ? user.email : undefined,
unoccupied: unoccupiedOnly,
})
.then(t => { setTasks(t); setError(null) })
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [statusFilter, categoryFilter, mineOnly, unoccupiedOnly, user.email])
useEffect(() => { load() }, [load])
useEffect(() => {
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(() => {})
fetchConfig().then(setConfig).catch(() => {})
}, [])
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 roomsCategoryExists = categories.some(c => c.is_rooms)
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={() => setShowNew(true)}>
<Plus size={14} strokeWidth={1.75} /> Report fault
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<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 className="chip-bar">
{categories.map(c => (
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
{c.name}
</button>
))}
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
Mine
</button>
{roomsCategoryExists && (
<button
className={`chip ${unoccupiedOnly ? 'active' : ''}`}
title="Only rooms with no in-house guest right now (live from NewBook)"
onClick={() => setUnoccupiedOnly(!unoccupiedOnly)}
>
<BedDouble size={13} strokeWidth={1.75} /> Unoccupied rooms only
</button>
)}
</div>
{tasks.length === 0 && !loading && (
<div className="empty-state">No open tasks match these filters.</div>
)}
{tasks.map(t => (
<div
key={t.id}
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
onClick={() => setOpenTask(t.id)}
>
<div className="task-card-main">
<div className="task-card-title">
{t.title}
{t.unusable && <UnusableBadge />}
{t.newbook_blocked && <span className="badge badge-outline">NB blocked</span>}
{t.template_id && <span className="badge badge-outline">Recurring</span>}
</div>
<div className="task-card-meta">
<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>
))}
{showNew && (
<NewTaskModal
categories={categories}
locations={locations}
config={config}
onClose={() => setShowNew(false)}
onCreated={load}
/>
)}
{openTask !== null && (
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
)}
</div>
)
}