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,206 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, X } from 'lucide-react'
import type { Asset, AssetDetail, Location } from '../types'
import { can } from '../types'
import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations } from '../api'
import { useAuth } from '../components/AuthGate'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
interface AssetForm {
id?: number
name: string
location_id: number | ''
make_model: string
serial_no: string
install_date: string
notes: string
active: boolean
}
const EMPTY: AssetForm = { name: '', location_id: '', make_model: '', serial_no: '', install_date: '', notes: '', active: true }
export default function Assets() {
const { user } = useAuth()
const [assets, setAssets] = useState<Asset[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [detail, setDetail] = useState<AssetDetail | null>(null)
const [form, setForm] = useState<AssetForm | null>(null)
const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(null)
const canManage = can(user, 'manage_assets')
const load = useCallback(() => {
fetchAssets().then(setAssets).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { fetchLocations().then(d => setLocations(d.locations.filter(l => l.active))).catch(() => {}) }, [])
async function save() {
if (!form || !form.name.trim() || !form.location_id) { setError('Name and location required'); return }
try {
const body = {
name: form.name.trim(),
location_id: form.location_id,
make_model: form.make_model || null,
serial_no: form.serial_no || null,
install_date: form.install_date || null,
notes: form.notes || null,
active: form.active,
}
if (form.id) await updateAsset(form.id, body)
else await createAsset(body)
setForm(null)
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Asset register</h1>
{canManage && (
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
<Plus size={14} strokeWidth={1.75} /> Add asset
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Asset</th><th>Location</th><th>Make / model</th><th>Open tasks</th><th>Recurring</th></tr>
</thead>
<tbody>
{assets.map(a => (
<tr key={a.id} className="clickable" onClick={() => fetchAsset(a.id).then(setDetail).catch(err => setError(err.message))}>
<td>{a.name}</td>
<td>{a.location_name}</td>
<td>{a.make_model || '—'}</td>
<td>{a.open_tasks || 0}</td>
<td>{a.recurring_count || 0}</td>
</tr>
))}
{assets.length === 0 && <tr><td colSpan={5} className="empty-state">No assets yet — add the boiler, lifts, fridges…</td></tr>}
</tbody>
</table>
</div>
{/* Asset detail modal */}
{detail && (
<div className="modal-overlay" onClick={() => setDetail(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{detail.name}</h2>
<button className="modal-close" onClick={() => setDetail(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="task-card-meta" style={{ marginBottom: 10 }}>
<span>{detail.location_name}</span>
{detail.make_model && <span>{detail.make_model}</span>}
{detail.serial_no && <span>SN {detail.serial_no}</span>}
{detail.install_date && <span>Installed {formatDate(detail.install_date)}</span>}
</div>
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
{detail.templates.length > 0 && (
<>
<div className="section-title">Recurring service tasks</div>
{detail.templates.map(tp => (
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}>
<span>{tp.title}</span>
<span>every {tp.interval_value} {tp.interval_unit}</span>
<span>next due {formatDate(tp.next_due)}</span>
{!tp.active && <span className="badge badge-outline">paused</span>}
</div>
))}
</>
)}
<div className="section-title">Task history</div>
{detail.tasks.length === 0 && <div className="muted">No tasks logged against this asset.</div>}
{detail.tasks.map(t => (
<div key={t.id} className="timeline-item clickable" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
<div className="timeline-body">
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={t.status} /> <PriorityBadge priority={t.priority} /> {t.title}
</div>
<div className="timeline-meta">
{formatDate(t.created_at)}{t.completed_at ? ` → ${formatDate(t.completed_at)} by ${t.completed_by_name}` : ''}
</div>
</div>
</div>
))}
{canManage && (
<div className="modal-actions">
<button className="btn" onClick={() => {
setForm({
id: detail.id, name: detail.name, location_id: detail.location_id,
make_model: detail.make_model || '', serial_no: detail.serial_no || '',
install_date: detail.install_date?.slice(0, 10) || '', notes: detail.notes || '',
active: detail.active,
})
setDetail(null)
}}>Edit</button>
</div>
)}
</div>
</div>
)}
{/* Asset create/edit modal */}
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit asset' : 'Add asset'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field"><label>Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="e.g. Kitchen walk-in fridge" autoFocus />
</div>
<div className="field"><label>Location</label>
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '' })}>
<option value="">Select location…</option>
{locations.map(l => <option key={l.id} value={l.id}>{l.name} ({l.category_name})</option>)}
</select>
</div>
<div className="field-row">
<div className="field"><label>Make / model</label>
<input type="text" value={form.make_model} onChange={e => setForm({ ...form, make_model: e.target.value })} />
</div>
<div className="field"><label>Serial no</label>
<input type="text" value={form.serial_no} onChange={e => setForm({ ...form, serial_no: e.target.value })} />
</div>
</div>
<div className="field"><label>Install date</label>
<input type="date" value={form.install_date} onChange={e => setForm({ ...form, install_date: e.target.value })} />
</div>
<div className="field"><label>Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
{form.id && (
<label className="field-check">
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
Active
</label>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save}>Save</button>
</div>
</div>
</div>
)}
{openTask !== null && <TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />}
</div>
)
}

View file

@ -0,0 +1,238 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, X, FileText, Trash2, Upload } from 'lucide-react'
import type { Contractor, ContractorDetail } from '../types'
import { can } from '../types'
import {
fetchContractors, fetchContractor, createContractor, updateContractor,
uploadContractorDoc, deleteContractorDoc, photoUrl,
} from '../api'
import { useAuth } from '../components/AuthGate'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
interface ContractorForm {
id?: number
name: string
company: string
phone: string
email: string
address: string
notes: string
active: boolean
}
const EMPTY: ContractorForm = { name: '', company: '', phone: '', email: '', address: '', notes: '', active: true }
export default function Contractors() {
const { user } = useAuth()
const [contractors, setContractors] = useState<Contractor[]>([])
const [detail, setDetail] = useState<ContractorDetail | null>(null)
const [form, setForm] = useState<ContractorForm | null>(null)
const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(null)
const [docType, setDocType] = useState('')
const [docExpiry, setDocExpiry] = useState('')
const canManage = can(user, 'manage_contractors')
const load = useCallback(() => {
fetchContractors().then(setContractors).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
const reloadDetail = (id: number) => fetchContractor(id).then(setDetail).catch(err => setError(err.message))
async function save() {
if (!form || !form.name.trim()) { setError('Name required'); return }
try {
const body = {
name: form.name.trim(), company: form.company || null, phone: form.phone || null,
email: form.email || null, address: form.address || null, notes: form.notes || null, active: form.active,
}
if (form.id) await updateContractor(form.id, body)
else await createContractor(body)
setForm(null)
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
const expiringSoon = (d: string | null | undefined) =>
d && new Date(d).getTime() < Date.now() + 30 * 86400000
return (
<div className="page">
<div className="page-header">
<h1>Contractors</h1>
{canManage && (
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
<Plus size={14} strokeWidth={1.75} /> Add contractor
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Name</th><th>Company</th><th>Phone</th><th>Open tasks</th><th>Docs</th></tr>
</thead>
<tbody>
{contractors.map(c => (
<tr key={c.id} className="clickable" onClick={() => reloadDetail(c.id)}>
<td>{c.name}</td>
<td>{c.company || '—'}</td>
<td>{c.phone || '—'}</td>
<td>{c.open_tasks || 0}</td>
<td>
{c.doc_count || 0}
{expiringSoon(c.earliest_doc_expiry) && <span className="overdue"> · doc expiring</span>}
</td>
</tr>
))}
{contractors.length === 0 && <tr><td colSpan={5} className="empty-state">No contractors yet.</td></tr>}
</tbody>
</table>
</div>
{/* Detail modal */}
{detail && (
<div className="modal-overlay" onClick={() => setDetail(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{detail.name}{detail.company ? ` — ${detail.company}` : ''}</h2>
<button className="modal-close" onClick={() => setDetail(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="task-card-meta" style={{ marginBottom: 10 }}>
{detail.phone && <span>{detail.phone}</span>}
{detail.email && <span>{detail.email}</span>}
{detail.address && <span>{detail.address}</span>}
</div>
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
<div className="section-title">Documents</div>
{detail.docs.length === 0 && <div className="muted">No documents uploaded.</div>}
{detail.docs.map(d => (
<div key={d.id} className="timeline-item">
<span className="timeline-icon"><FileText size={14} strokeWidth={1.75} /></span>
<div className="timeline-body">
<a href={photoUrl(d.file_path)} target="_blank" rel="noreferrer">{d.doc_type || d.file_name}</a>
<div className="timeline-meta">
{d.expiry_date
? <span className={expiringSoon(d.expiry_date) ? 'overdue' : ''}>expires {formatDate(d.expiry_date)}</span>
: 'no expiry'}
{' · '}uploaded {formatDate(d.uploaded_at)}
</div>
</div>
{canManage && (
<button className="btn btn-sm" onClick={() => deleteContractorDoc(d.id).then(() => reloadDetail(detail.id))}>
<Trash2 size={13} strokeWidth={1.75} />
</button>
)}
</div>
))}
{canManage && (
<div className="card" style={{ marginTop: 8 }}>
<div className="field-row">
<div className="field"><label>Document type</label>
<input type="text" value={docType} onChange={e => setDocType(e.target.value)} placeholder="e.g. Liability insurance" />
</div>
<div className="field"><label>Expiry (optional)</label>
<input type="date" value={docExpiry} onChange={e => setDocExpiry(e.target.value)} />
</div>
</div>
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
<Upload size={13} strokeWidth={1.75} /> Upload document
<input
type="file" accept="image/*,application/pdf" style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0]
if (f) uploadContractorDoc(detail.id, f, docType, docExpiry)
.then(() => { setDocType(''); setDocExpiry(''); reloadDetail(detail.id); load() })
.catch(err => setError(err.message))
}}
/>
</label>
</div>
)}
<div className="section-title">Recent tasks</div>
{detail.tasks.length === 0 && <div className="muted">No tasks allocated yet.</div>}
{detail.tasks.map(t => (
<div key={t.id} className="timeline-item" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
<div className="timeline-body">
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={t.status} /> <PriorityBadge priority={t.priority} /> {t.title}
</div>
<div className="timeline-meta">{t.location_name} · {formatDate(t.created_at)}</div>
</div>
</div>
))}
{canManage && (
<div className="modal-actions">
<button className="btn" onClick={() => {
setForm({
id: detail.id, name: detail.name, company: detail.company || '', phone: detail.phone || '',
email: detail.email || '', address: detail.address || '', notes: detail.notes || '', active: detail.active,
})
setDetail(null)
}}>Edit</button>
</div>
)}
</div>
</div>
)}
{/* Create/edit modal */}
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit contractor' : 'Add contractor'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field-row">
<div className="field"><label>Contact name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} autoFocus />
</div>
<div className="field"><label>Company</label>
<input type="text" value={form.company} onChange={e => setForm({ ...form, company: e.target.value })} />
</div>
</div>
<div className="field-row">
<div className="field"><label>Phone</label>
<input type="text" value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} />
</div>
<div className="field"><label>Email</label>
<input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
</div>
</div>
<div className="field"><label>Address</label>
<input type="text" value={form.address} onChange={e => setForm({ ...form, address: e.target.value })} />
</div>
<div className="field"><label>Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
{form.id && (
<label className="field-check">
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
Active
</label>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save}>Save</button>
</div>
</div>
</div>
)}
{openTask !== null && <TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={() => detail && reloadDetail(detail.id)} />}
</div>
)
}

View file

@ -0,0 +1,128 @@
import { useCallback, useEffect, useState } from 'react'
import { Search, Download } from 'lucide-react'
import type { Task, Category } from '../types'
import { can } from '../types'
import { fetchHistory, fetchLocations, historyExportUrl, type HistoryFilters } from '../api'
import { useAuth } from '../components/AuthGate'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
export default function HistoryPage() {
const { user } = useAuth()
const [tasks, setTasks] = useState<Task[]>([])
const [totals, setTotals] = useState<{ count: number; total_cost: string | null; avg_days_to_fix: string | null } | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(null)
const [q, setQ] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
const [includeTemporary, setIncludeTemporary] = useState(false)
const filters: HistoryFilters = {
q: q || undefined,
from: from || undefined,
to: to || undefined,
category_id: categoryFilter ?? undefined,
include_temporary: includeTemporary,
}
const load = useCallback(() => {
fetchHistory(filters)
.then(d => { setTasks(d.tasks); setTotals(d.totals); setError(null) })
.catch(err => setError(err.message))
}, [q, from, to, categoryFilter, includeTemporary])
useEffect(() => {
const t = setTimeout(load, q ? 300 : 0) // debounce typing
return () => clearTimeout(t)
}, [load])
useEffect(() => { fetchLocations().then(d => setCategories(d.categories)).catch(() => {}) }, [])
const showCosts = can(user, 'costs')
return (
<div className="page">
<div className="page-header">
<h1>History</h1>
<a className="btn" href={historyExportUrl(filters)}>
<Download size={14} strokeWidth={1.75} /> CSV
</a>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="field-row" style={{ flexWrap: 'wrap' }}>
<div className="field" style={{ flex: 2, minWidth: 200 }}>
<label><Search size={11} strokeWidth={1.75} /> Search</label>
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="Title, description or location…" />
</div>
<div className="field"><label>From</label><input type="date" value={from} onChange={e => setFrom(e.target.value)} /></div>
<div className="field"><label>To</label><input type="date" value={to} onChange={e => setTo(e.target.value)} /></div>
</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 ${includeTemporary ? 'active' : ''}`} onClick={() => setIncludeTemporary(!includeTemporary)}>
Include temporary fixes
</button>
</div>
{totals && (
<div className="stats-strip">
<div className="stat-box"><div className="stat-value">{totals.count}</div><div className="stat-label">Fixed</div></div>
{showCosts && totals.total_cost != null && (
<div className="stat-box"><div className="stat-value">£{totals.total_cost}</div><div className="stat-label">Total cost</div></div>
)}
{totals.avg_days_to_fix != null && (
<div className="stat-box"><div className="stat-value">{totals.avg_days_to_fix}</div><div className="stat-label">Avg days to fix</div></div>
)}
</div>
)}
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Task</th>
<th>Location</th>
<th>Priority</th>
<th>Status</th>
<th>Reported</th>
<th>Completed</th>
<th>By</th>
{showCosts && <th>Cost</th>}
</tr>
</thead>
<tbody>
{tasks.map(t => (
<tr key={t.id} className="clickable" onClick={() => setOpenTask(t.id)}>
<td>{t.title}</td>
<td>{t.location_name}</td>
<td><PriorityBadge priority={t.priority} /></td>
<td><StatusBadge status={t.status} /></td>
<td>{formatDate(t.created_at)}</td>
<td>{formatDate(t.completed_at)}</td>
<td>{t.completed_by_name || '—'}</td>
{showCosts && <td>{t.cost != null ? `£${t.cost}` : '—'}</td>}
</tr>
))}
{tasks.length === 0 && (
<tr><td colSpan={showCosts ? 8 : 7} className="empty-state">No fixed tasks match.</td></tr>
)}
</tbody>
</table>
</div>
{openTask !== null && (
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
)}
</div>
)
}

View file

@ -0,0 +1,190 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, RefreshCw, X } from 'lucide-react'
import type { Category, Location } from '../types'
import {
fetchLocations, createLocation, updateLocation, syncNewbookRooms,
createCategory, updateCategory, deleteCategory,
} from '../api'
export default function Locations() {
const [categories, setCategories] = useState<Category[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [syncing, setSyncing] = useState(false)
const [newName, setNewName] = useState('')
const [newCategoryId, setNewCategoryId] = useState<number | ''>('')
const [newCatName, setNewCatName] = useState('')
const [editLoc, setEditLoc] = useState<Location | null>(null)
const load = useCallback(() => {
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
async function addLocation() {
if (!newName.trim() || !newCategoryId) { setError('Location name and category required'); return }
try {
await createLocation({ name: newName.trim(), category_id: newCategoryId as number })
setNewName('')
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed')
}
}
async function addCategory() {
if (!newCatName.trim()) return
try {
await createCategory({ name: newCatName.trim(), sort_order: categories.length + 1 })
setNewCatName('')
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed')
}
}
async function doSync() {
setSyncing(true)
setError(null)
try {
const r = await syncNewbookRooms()
setInfo(`NewBook sync: ${r.created} added, ${r.updated} updated (${r.total} rooms)`)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Sync failed')
} finally {
setSyncing(false)
}
}
return (
<div className="page">
<div className="page-header">
<h1>Locations</h1>
<button className="btn" onClick={doSync} disabled={syncing}>
<RefreshCw size={14} strokeWidth={1.75} /> {syncing ? 'Syncing…' : 'Sync NewBook rooms'}
</button>
</div>
{error && <div className="error-banner">{error}</div>}
{info && <div className="card" style={{ background: 'var(--ok-bg)' }}>{info}</div>}
{/* Add manual location */}
<div className="card">
<div className="field-row" style={{ alignItems: 'flex-end' }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>New location</label>
<input type="text" value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. Main kitchen, Bar, Terrace" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Category</label>
<select value={newCategoryId} onChange={e => setNewCategoryId(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">Select…</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<button className="btn btn-primary" onClick={addLocation} style={{ marginBottom: 1 }}>
<Plus size={14} strokeWidth={1.75} /> Add
</button>
</div>
</div>
{categories.map(c => {
const locs = locations.filter(l => l.category_id === c.id)
return (
<div key={c.id}>
<div className="section-title">
{c.name} {c.is_rooms && <span className="badge badge-outline">NewBook rooms</span>} ({locs.filter(l => l.active).length})
</div>
<div className="chip-bar">
{locs.map(l => (
<button
key={l.id}
className="chip"
style={l.active ? undefined : { opacity: .45, textDecoration: 'line-through' }}
title={l.source === 'newbook' ? `NewBook site ${l.newbook_site_id}` : 'Manual location — click to edit'}
onClick={() => setEditLoc(l)}
>
{l.name}
</button>
))}
{locs.length === 0 && <span className="muted" style={{ fontSize: 12.5 }}>none</span>}
{c.is_rooms && locs.length === 0 && <span className="muted" style={{ fontSize: 12.5 }}>— run the NewBook sync</span>}
</div>
</div>
)
})}
{/* Category management */}
<div className="section-title">Categories</div>
<div className="card">
{categories.map(c => (
<div key={c.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<span style={{ flex: 1 }}>{c.name}{c.is_rooms ? ' (rooms)' : ''}</span>
<button className="btn btn-sm" onClick={() => {
const name = window.prompt('Rename category', c.name)
if (name && name !== c.name) updateCategory(c.id, { name }).then(load).catch(err => setError(err.message))
}}>Rename</button>
{!c.is_rooms && (
<button className="btn btn-sm" onClick={() => {
if (window.confirm(`Delete category "${c.name}"? Only possible when it has no locations.`)) {
deleteCategory(c.id).then(load).catch(err => setError(err.message))
}
}}>Delete</button>
)}
</div>
))}
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 10 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>New category</label>
<input type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)} placeholder="e.g. Plant Rooms" />
</div>
<button className="btn" onClick={addCategory} style={{ marginBottom: 1 }}>
<Plus size={14} strokeWidth={1.75} /> Add
</button>
</div>
</div>
{/* Edit location modal */}
{editLoc && (
<div className="modal-overlay" onClick={() => setEditLoc(null)}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">
<h2>{editLoc.name}</h2>
<button className="modal-close" onClick={() => setEditLoc(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
{editLoc.source === 'newbook' ? (
<p className="muted">NewBook room (site {editLoc.newbook_site_id}) — name and status come from the sync.</p>
) : (
<>
<div className="field"><label>Name</label>
<input type="text" value={editLoc.name} onChange={e => setEditLoc({ ...editLoc, name: e.target.value })} />
</div>
<div className="field"><label>Category</label>
<select value={editLoc.category_id} onChange={e => setEditLoc({ ...editLoc, category_id: parseInt(e.target.value) })}>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</>
)}
<label className="field-check">
<input type="checkbox" checked={editLoc.active} onChange={e => setEditLoc({ ...editLoc, active: e.target.checked })} />
Active
</label>
<div className="modal-actions">
<button className="btn" onClick={() => setEditLoc(null)}>Cancel</button>
<button className="btn btn-primary" onClick={() => {
updateLocation(editLoc.id, {
name: editLoc.name, category_id: editLoc.category_id, active: editLoc.active,
}).then(() => { setEditLoc(null); load() }).catch(err => setError(err.message))
}}>Save</button>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,230 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X, Play } from 'lucide-react'
import type { Template, Category, Location, Asset, Priority } from '../types'
import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
import { fetchTemplates, createTemplate, updateTemplate, runDueTemplates, fetchLocations, fetchAssets } from '../api'
import { useAuth } from '../components/AuthGate'
import AssigneeSelect, { type Assignment } from '../components/AssigneeSelect'
import { PriorityBadge, formatDate } from '../components/shared'
interface TemplateForm {
id?: number
title: string
description: string
location_id: number | ''
asset_id: number | ''
priority: Priority
unusable: boolean
interval_value: number
interval_unit: 'days' | 'weeks' | 'months'
next_due: string
template_notes: string
active: boolean
assignment: Assignment
}
const EMPTY: TemplateForm = {
title: '', description: '', location_id: '', asset_id: '', priority: 'medium', unusable: false,
interval_value: 1, interval_unit: 'months', next_due: '', template_notes: '', active: true,
assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null },
}
export default function Recurring() {
const { user } = useAuth()
const [templates, setTemplates] = useState<Template[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [form, setForm] = useState<TemplateForm | null>(null)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const canManage = can(user, 'manage_templates')
const load = useCallback(() => {
fetchTemplates().then(setTemplates).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations.filter(l => l.active)) }).catch(() => {})
fetchAssets().then(setAssets).catch(() => {})
}, [])
const grouped = useMemo(() => categories.map(c => ({
category: c,
locations: locations.filter(l => l.category_id === c.id),
})).filter(g => g.locations.length), [categories, locations])
const formAssets = useMemo(
() => assets.filter(a => form && a.location_id === form.location_id),
[assets, form?.location_id]
)
async function save() {
if (!form || !form.title.trim() || !form.location_id || !form.next_due) {
setError('Title, location and first due date are required')
return
}
try {
const body = {
title: form.title.trim(),
description: form.description || null,
location_id: form.location_id,
asset_id: form.asset_id || null,
priority: form.priority,
unusable: form.unusable,
interval_value: form.interval_value,
interval_unit: form.interval_unit,
next_due: form.next_due,
template_notes: form.template_notes,
active: form.active,
assigned_type: form.assignment.assigned_type,
assigned_to: form.assignment.assigned_to,
assigned_to_name: form.assignment.assigned_to_name,
contractor_id: form.assignment.contractor_id,
}
if (form.id) await updateTemplate(form.id, body)
else await createTemplate(body)
setForm(null)
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Recurring tasks</h1>
{canManage && (
<>
<button className="btn" onClick={() => runDueTemplates().then(r => { setInfo(`Spawned ${r.spawned} due task(s)`); load() }).catch(err => setError(err.message))}>
<Play size={14} strokeWidth={1.75} /> Run due now
</button>
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
<Plus size={14} strokeWidth={1.75} /> New template
</button>
</>
)}
</div>
{error && <div className="error-banner">{error}</div>}
{info && <div className="card" style={{ background: 'var(--ok-bg)' }}>{info}</div>}
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Task</th><th>Location</th><th>Asset</th><th>Priority</th><th>Every</th><th>Next due</th><th>Allocated</th><th></th></tr>
</thead>
<tbody>
{templates.map(t => (
<tr key={t.id} className={canManage ? 'clickable' : ''} onClick={() => canManage && setForm({
id: t.id, title: t.title, description: t.description || '',
location_id: t.location_id, asset_id: t.asset_id || '', priority: t.priority,
unusable: t.unusable, interval_value: t.interval_value, interval_unit: t.interval_unit,
next_due: t.next_due.slice(0, 10), template_notes: t.template_notes, active: t.active,
assignment: {
assigned_type: t.assigned_type, assigned_to: t.assigned_to,
assigned_to_name: t.assigned_to_name, contractor_id: t.contractor_id,
},
})}>
<td>{t.title}</td>
<td>{t.location_name}</td>
<td>{t.asset_name || '—'}</td>
<td><PriorityBadge priority={t.priority} /></td>
<td>{t.interval_value} {t.interval_unit}</td>
<td className={new Date(t.next_due) <= new Date() ? 'overdue' : ''}>{formatDate(t.next_due)}</td>
<td>{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}</td>
<td>{!t.active && <span className="badge badge-outline">paused</span>}</td>
</tr>
))}
{templates.length === 0 && (
<tr><td colSpan={8} className="empty-state">No recurring templates — set up fire alarm tests, boiler service, legionella flushing…</td></tr>
)}
</tbody>
</table>
</div>
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit template' : 'New recurring template'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field"><label>Title</label>
<input type="text" value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="e.g. Fire alarm weekly test" autoFocus />
</div>
<div className="field"><label>Location</label>
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '', asset_id: '' })}>
<option value="">Select location…</option>
{grouped.map(g => (
<optgroup key={g.category.id} label={g.category.name}>
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</optgroup>
))}
</select>
</div>
{formAssets.length > 0 && (
<div className="field"><label>Asset (optional)</label>
<select value={form.asset_id} onChange={e => setForm({ ...form, asset_id: e.target.value ? parseInt(e.target.value) : '' })}>
<option value="">None</option>
{formAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
)}
<div className="field"><label>Description</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="field"><label>Priority</label>
<div className="chip-bar" style={{ marginBottom: 0 }}>
{PRIORITIES.map(p => (
<button key={p} type="button" className={`chip ${form.priority === p ? 'active' : ''}`} onClick={() => setForm({ ...form, priority: p })}>
{PRIORITY_LABELS[p]}
</button>
))}
</div>
</div>
<div className="field-row">
<div className="field"><label>Repeat every</label>
<input type="number" min={1} value={form.interval_value} onChange={e => setForm({ ...form, interval_value: parseInt(e.target.value) || 1 })} />
</div>
<div className="field"><label>Unit</label>
<select value={form.interval_unit} onChange={e => setForm({ ...form, interval_unit: e.target.value as TemplateForm['interval_unit'] })}>
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
</select>
</div>
<div className="field"><label>{form.id ? 'Next due' : 'First due'}</label>
<input type="date" value={form.next_due} onChange={e => setForm({ ...form, next_due: e.target.value })} />
</div>
</div>
<AssigneeSelect value={form.assignment} onChange={a => setForm({ ...form, assignment: a })} />
<div className="field">
<label>Template notes (shown on every occurrence)</label>
<textarea value={form.template_notes} onChange={e => setForm({ ...form, template_notes: e.target.value })} placeholder="Accumulated tips from previous visits…" />
<div className="field-hint">Notes added from a spawned task with “add to template” ticked land here automatically.</div>
</div>
{form.id && (
<label className="field-check">
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
Active (untick to pause the schedule)
</label>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save}>Save</button>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { Save } from 'lucide-react'
import type { AppConfig, AuthUser, Contractor } from '../types'
import { fetchConfig, updateConfig, fetchAssignableUsers, fetchContractors } from '../api'
export default function Settings() {
const [config, setConfig] = useState<AppConfig | null>(null)
const [users, setUsers] = useState<AuthUser[]>([])
const [contractors, setContractors] = useState<Contractor[]>([])
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
useEffect(() => {
fetchConfig().then(setConfig).catch(err => setError(err.message))
fetchAssignableUsers().then(setUsers).catch(() => {})
fetchContractors().then(setContractors).catch(() => {})
}, [])
if (!config) return <div className="page">{error ? <div className="error-banner">{error}</div> : 'Loading…'}</div>
async function saveAll() {
if (!config) return
setError(null)
setSaved(false)
try {
for (const [key, value] of Object.entries(config)) {
await updateConfig(key, value)
}
setSaved(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
const set = (patch: Partial<AppConfig>) => { setConfig({ ...config, ...patch }); setSaved(false) }
return (
<div className="page" style={{ maxWidth: 640 }}>
<div className="page-header">
<h1>Settings</h1>
<button className="btn btn-primary" onClick={saveAll}>
<Save size={14} strokeWidth={1.75} /> Save
</button>
</div>
{error && <div className="error-banner">{error}</div>}
{saved && <div className="card" style={{ background: 'var(--ok-bg)' }}>Settings saved.</div>}
<div className="section-title">Default allocation</div>
<div className="card">
<div className="field">
<label>Default allocation type</label>
<select value={config.default_assigned_type} onChange={e => set({ default_assigned_type: e.target.value as AppConfig['default_assigned_type'] })}>
<option value="staff">Staff</option>
<option value="contractor">Contractor</option>
</select>
</div>
{config.default_assigned_type === 'staff' ? (
<div className="field">
<label>Default staff member</label>
<select
value={config.default_assignee}
onChange={e => {
const u = users.find(x => x.email === e.target.value)
set({ default_assignee: e.target.value, default_assignee_name: u?.name || '' })
}}
>
<option value="">Unassigned</option>
{users.map(u => <option key={u.email} value={u.email}>{u.name}</option>)}
</select>
<div className="field-hint">New tasks are allocated here unless the reporter picks someone else.</div>
</div>
) : (
<div className="field">
<label>Default contractor</label>
<select
value={config.default_contractor_id ?? ''}
onChange={e => set({ default_contractor_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">None</option>
{contractors.map(c => <option key={c.id} value={c.id}>{c.name}{c.company ? ` — ${c.company}` : ''}</option>)}
</select>
</div>
)}
</div>
<div className="section-title">Notifications</div>
<div className="card">
<label className="field-check" style={{ marginBottom: 10 }}>
<input type="checkbox" checked={config.notify_on_assign} onChange={e => set({ notify_on_assign: e.target.checked })} />
Email the assignee when a task is allocated or reallocated
</label>
<label className="field-check" style={{ marginBottom: 10 }}>
<input type="checkbox" checked={config.notify_on_urgent} onChange={e => set({ notify_on_urgent: e.target.checked })} />
Email when an urgent task is logged
</label>
<div className="field">
<label>Urgent notification address</label>
<input type="email" value={config.urgent_notify_email} onChange={e => set({ urgent_notify_email: e.target.value })} placeholder="maintenance@…" />
</div>
<div className="field-hint">Uses the stack SMTP settings (Settings app → integrations).</div>
</div>
<div className="section-title">NewBook room blocking</div>
<div className="card">
<div className="field-row">
<div className="field">
<label>Status set when blocking a room</label>
<input type="text" value={config.newbook_block_status} onChange={e => set({ newbook_block_status: e.target.value })} />
</div>
<div className="field">
<label>Status set when releasing a room</label>
<input type="text" value={config.newbook_unblock_status} onChange={e => set({ newbook_unblock_status: e.target.value })} />
</div>
</div>
<div className="field-hint">
Must match NewBook site status values exactly (e.g. Maintenance, Dirty). Blocking is always an explicit
per-task confirmation — never automatic.
</div>
</div>
</div>
)
}

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>
)
}