maintenance/frontend/src/pages/Recurring.tsx
jtricerolph d9b01a5981 Add lead time to recurring templates — generate before actually due
task_templates.lead_days (default 0): scheduler now spawns the task
when (next_due - lead_days) <= today instead of next_due <= today, but
due_date on the created task is still the real next_due — so overdue
and priority handling are unaffected, the task just becomes visible
earlier for prep/booking. Small jobs (AC filter clean) can stay at 0;
bigger ones needing advance notice (septic tank empty-out, contractor
booking) can set 2-3 days or 30 days as needed.

- db.js: lead_days INT NOT NULL DEFAULT 0 (inline for fresh installs,
  ALTER for existing).
- scheduler.js: WHERE (next_due - lead_days) <= $1 — next_due itself
  and the fixed/floating rollover logic are untouched by this.
- templates.js: accept + validate lead_days (>= 0) on create/update.
- Recurring.tsx: lead time field in the template form with contextual
  hint text, plus a small 'generates Nd early' note on list rows that
  have one set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:20:50 +00:00

443 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X, Play, Copy, ChevronRight, Trash2 } from 'lucide-react'
import type { Template, TemplateGroup, Category, Location, Asset, Priority, ScheduleMode } from '../types'
import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
import {
fetchTemplates, createTemplate, updateTemplate, deleteTemplate, runDueTemplates, fetchLocations, fetchAssets,
fetchTemplateGroups, upsertTemplateGroup,
} 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
lead_days: number
template_notes: string
active: boolean
schedule_mode: ScheduleMode
group_name: string
assignment: Assignment
// Bulk creation only — never sent as-is, resolved into one row per location.
multiLocation: boolean
location_ids: number[]
}
const EMPTY: TemplateForm = {
title: '', description: '', location_id: '', asset_id: '', priority: 'medium', unusable: false,
interval_value: 1, interval_unit: 'months', next_due: '', lead_days: 0, template_notes: '', active: true,
schedule_mode: 'fixed', group_name: '',
assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null },
multiLocation: false, location_ids: [],
}
export default function Recurring() {
const { user } = useAuth()
const [templates, setTemplates] = useState<Template[]>([])
const [groups, setGroups] = useState<TemplateGroup[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [form, setForm] = useState<TemplateForm | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
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))
fetchTemplateGroups().then(setGroups).catch(() => {})
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations.filter(l => l.active)) }).catch(() => {})
fetchAssets().then(setAssets).catch(() => {})
}, [])
const locationGroups = 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]
)
const { groupSections, ungrouped } = useMemo(() => {
const byGroup = new Map<string, Template[]>()
const solo: Template[] = []
for (const t of templates) {
if (t.group_name) {
if (!byGroup.has(t.group_name)) byGroup.set(t.group_name, [])
byGroup.get(t.group_name)!.push(t)
} else {
solo.push(t)
}
}
const sections = [...byGroup.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, members]) => ({ name, templates: members }))
return { groupSections: sections, ungrouped: solo }
}, [templates])
function toggleGroup(name: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function openEdit(t: Template) {
if (!canManage) return
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), lead_days: t.lead_days, template_notes: t.template_notes, active: t.active,
schedule_mode: t.schedule_mode, group_name: t.group_name || '',
assignment: {
assigned_type: t.assigned_type, assigned_to: t.assigned_to,
assigned_to_name: t.assigned_to_name, contractor_id: t.contractor_id,
},
multiLocation: false, location_ids: [],
})
}
// Prefills a new template from an existing one — location/asset/due date are
// cleared since the whole point is picking a *different* room. Lands in the
// same group automatically so a one-off addition to "Clean AC filter" etc.
// doesn't need the full multi-location picker.
function duplicate(t: Template) {
setForm({
title: t.title, description: t.description || '',
location_id: '', 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), lead_days: t.lead_days, template_notes: t.template_notes, active: true,
schedule_mode: t.schedule_mode, group_name: t.group_name || '',
assignment: {
assigned_type: t.assigned_type, assigned_to: t.assigned_to,
assigned_to_name: t.assigned_to_name, contractor_id: t.contractor_id,
},
multiLocation: false, location_ids: [],
})
}
function toggleFormLocation(id: number) {
if (!form) return
setForm({
...form,
location_ids: form.location_ids.includes(id)
? form.location_ids.filter(x => x !== id)
: [...form.location_ids, id],
})
}
async function save() {
if (!form) return
if (!form.title.trim()) { setError('Title is required'); return }
if (!form.next_due) { setError(form.id ? 'Next due date is required' : 'First due date is required'); return }
if (form.multiLocation) {
if (!form.group_name.trim()) { setError('A group name is required when creating for multiple locations'); return }
if (form.location_ids.length === 0) { setError('Select at least one location'); return }
} else if (!form.location_id) {
setError('Location is required')
return
}
try {
const groupId = form.group_name.trim() ? (await upsertTemplateGroup(form.group_name.trim())).id : null
const base = {
title: form.title.trim(),
description: form.description || null,
priority: form.priority,
unusable: form.unusable,
interval_value: form.interval_value,
interval_unit: form.interval_unit,
next_due: form.next_due,
lead_days: form.lead_days,
template_notes: form.template_notes,
active: form.active,
schedule_mode: form.schedule_mode,
group_id: groupId,
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.multiLocation) {
await Promise.all(form.location_ids.map(location_id =>
createTemplate({ ...base, location_id, asset_id: null })
))
setInfo(`Created ${form.location_ids.length} templates in "${form.group_name.trim()}"`)
} else {
const body = { ...base, location_id: form.location_id, asset_id: form.asset_id || null }
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')
}
}
async function remove(id: number) {
if (!window.confirm('Delete this recurring template? Any tasks it already spawned stay in your history, but the schedule stops.')) return
try {
await deleteTemplate(id)
setForm(null)
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed')
}
}
function renderRow(t: Template) {
return (
<tr key={t.id} className={canManage ? 'clickable' : ''} onClick={() => openEdit(t)}>
<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}{t.schedule_mode === 'floating' && <span className="field-hint" style={{ display: 'block' }}>after completion</span>}</td>
<td className={!t.pending_task_id && new Date(t.next_due) <= new Date() ? 'overdue' : ''}>
{t.schedule_mode === 'floating' && t.pending_task_id
? <span className="badge badge-outline">awaiting completion</span>
: formatDate(t.next_due)}
{t.lead_days > 0 && <span className="field-hint" style={{ display: 'block' }}>generates {t.lead_days}d early</span>}
</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>
<td>
{canManage && (
<button className="btn btn-sm" title="Duplicate to another location" onClick={e => { e.stopPropagation(); duplicate(t) }}>
<Copy size={13} strokeWidth={1.75} />
</button>
)}
</td>
</tr>
)
}
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><th></th></tr>
</thead>
<tbody>
{groupSections.map(g => {
const isOpen = !collapsedGroups.has(g.name)
return (
<Fragment key={g.name}>
<tr className="group-row" onClick={() => toggleGroup(g.name)}>
<td colSpan={9}>
<ChevronRight size={14} strokeWidth={1.75} className={`group-row-chevron${isOpen ? ' open' : ''}`} />
{g.name}
<span className="badge badge-outline">{g.templates.length} location{g.templates.length === 1 ? '' : 's'}</span>
</td>
</tr>
{isOpen && g.templates.map(renderRow)}
</Fragment>
)
})}
{ungrouped.map(renderRow)}
{templates.length === 0 && (
<tr><td colSpan={9} 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>Group / folder (optional)</label>
<input
type="text" list="template-group-names" value={form.group_name}
onChange={e => setForm({ ...form, group_name: e.target.value })}
placeholder="e.g. Clean AC filter"
/>
<datalist id="template-group-names">
{groups.map(g => <option key={g.id} value={g.name} />)}
</datalist>
<div className="field-hint">Groups the same recurring job across rooms into one folder on the list below. Type an existing name to add to it, or a new one to start a folder.</div>
</div>
{!form.id && (
<label className="field-check">
<input type="checkbox" checked={form.multiLocation} onChange={e => setForm({ ...form, multiLocation: e.target.checked, location_ids: [] })} />
Create for multiple locations at once
</label>
)}
{form.multiLocation ? (
<div className="field">
<label>Locations</label>
<div className="table-wrap" style={{ maxHeight: 220, overflowY: 'auto', padding: '4px 10px' }}>
{locationGroups.map(g => (
<div key={g.category.id} style={{ marginBottom: 8 }}>
<div className="field-hint" style={{ fontWeight: 600, margin: '6px 0 2px' }}>{g.category.name}</div>
{g.locations.map(l => (
<label key={l.id} className="field-check" style={{ marginBottom: 2 }}>
<input type="checkbox" checked={form.location_ids.includes(l.id)} onChange={() => toggleFormLocation(l.id)} />
{l.name}
</label>
))}
</div>
))}
</div>
<div className="field-hint">{form.location_ids.length} location{form.location_ids.length === 1 ? '' : 's'} selected — one template is created per location, all in the group above.</div>
</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>
{locationGroups.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">
<label>Schedule</label>
<div className="chip-bar" style={{ marginBottom: 6 }}>
<button type="button" className={`chip ${form.schedule_mode === 'fixed' ? 'active' : ''}`} onClick={() => setForm({ ...form, schedule_mode: 'fixed' })}>
Fixed
</button>
<button type="button" className={`chip ${form.schedule_mode === 'floating' ? 'active' : ''}`} onClick={() => setForm({ ...form, schedule_mode: 'floating' })}>
Floating
</button>
</div>
<div className="field-hint">
{form.schedule_mode === 'fixed'
? 'Always due on the same recurring date, regardless of when each occurrence is actually completed.'
: 'The next occurrence is due one interval after each task is marked fixed — late or early completion shifts the whole schedule.'}
</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>
<div className="field"><label>Lead time (days before due to generate)</label>
<input type="number" min={0} value={form.lead_days} onChange={e => setForm({ ...form, lead_days: parseInt(e.target.value) || 0 })} style={{ maxWidth: 120 }} />
<div className="field-hint">
{form.lead_days > 0
? `Task appears ${form.lead_days} day${form.lead_days === 1 ? '' : 's'} before it's actually due — due date stays the real one, this just gives warning to prepare or book a contractor.`
: 'Leave at 0 for tasks that don’t need advance warning (e.g. clean AC filter). Larger jobs like a septic tank empty-out might want 30 days so you see it coming.'}
</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>
)}
{form.id && (
<div className="modal-footer-actions">
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => remove(form.id!)} title="Delete this recurring template">
<Trash2 size={13} strokeWidth={1.75} /> Delete template
</button>
</div>
)}
<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>
)
}