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:
commit
6ca395097e
47 changed files with 6727 additions and 0 deletions
206
frontend/src/pages/Assets.tsx
Normal file
206
frontend/src/pages/Assets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
238
frontend/src/pages/Contractors.tsx
Normal file
238
frontend/src/pages/Contractors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/pages/History.tsx
Normal file
128
frontend/src/pages/History.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
frontend/src/pages/Locations.tsx
Normal file
190
frontend/src/pages/Locations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
230
frontend/src/pages/Recurring.tsx
Normal file
230
frontend/src/pages/Recurring.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
frontend/src/pages/Settings.tsx
Normal file
123
frontend/src/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
frontend/src/pages/Summary.tsx
Normal file
159
frontend/src/pages/Summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue