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>
443 lines
20 KiB
TypeScript
443 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|