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([]) const [groups, setGroups] = useState([]) const [categories, setCategories] = useState([]) const [locations, setLocations] = useState([]) const [assets, setAssets] = useState([]) const [form, setForm] = useState(null) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const [error, setError] = useState(null) const [info, setInfo] = useState(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() 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 ( openEdit(t)}> {t.title} {t.location_name} {t.asset_name || '—'} {t.interval_value} {t.interval_unit}{t.schedule_mode === 'floating' && after completion} {t.schedule_mode === 'floating' && t.pending_task_id ? awaiting completion : formatDate(t.next_due)} {t.lead_days > 0 && generates {t.lead_days}d early} {t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')} {!t.active && paused} {canManage && ( )} ) } return (

Recurring tasks

{canManage && ( <> )}
{error &&
{error}
} {info &&
{info}
}
{groupSections.map(g => { const isOpen = !collapsedGroups.has(g.name) return ( toggleGroup(g.name)}> {isOpen && g.templates.map(renderRow)} ) })} {ungrouped.map(renderRow)} {templates.length === 0 && ( )}
TaskLocationAssetPriorityEveryNext dueAllocated
{g.name} {g.templates.length} location{g.templates.length === 1 ? '' : 's'}
No recurring templates — set up fire alarm tests, boiler service, legionella flushing…
{form && (
setForm(null)}>
e.stopPropagation()}>

{form.id ? 'Edit template' : 'New recurring template'}

setForm({ ...form, title: e.target.value })} placeholder="e.g. Fire alarm weekly test" autoFocus />
setForm({ ...form, group_name: e.target.value })} placeholder="e.g. Clean AC filter" /> {groups.map(g =>
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.
{!form.id && ( )} {form.multiLocation ? (
{locationGroups.map(g => (
{g.category.name}
{g.locations.map(l => ( ))}
))}
{form.location_ids.length} location{form.location_ids.length === 1 ? '' : 's'} selected — one template is created per location, all in the group above.
) : ( <>
{formAssets.length > 0 && (
)} )}