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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue