Add template groups: bulk multi-location creation + duplicate action

For recurring jobs that repeat identically across many locations (e.g.
clean AC filter in every room), one task_templates row per location is
still required — schedule/completion tracking is per-location, which
matters especially for floating mode — but creating and organising
that many rows by hand didn't scale.

- New template_groups table (id, unique name) + task_templates.group_id.
  POST /api/template-groups upserts by name so picking an existing
  group from the UI never creates a duplicate folder.
- Recurring template form: optional group name (datalist-autocompleted
  from existing groups) plus a "create for multiple locations" toggle
  that reveals a checkbox list of locations; saving loops one
  createTemplate() call per selected location, all sharing the
  resolved group_id.
- Duplicate button on each template row pre-fills the New Template
  form from that row (location/asset/next-due cleared for repicking),
  landing in the same group automatically — the lightweight path for
  adding one more room later without the bulk picker.
- Recurring.tsx list now groups rows under a collapsible folder header
  per group_name; ungrouped templates render as flat rows as before.
This commit is contained in:
jtricerolph 2026-08-26 10:33:19 +00:00
parent c740b87f1b
commit ee23cb1b16
6 changed files with 285 additions and 64 deletions

View file

@ -69,6 +69,15 @@ export async function initDb() {
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
-- Folders for recurring templates that are really the same job repeated
-- across many locations (e.g. "Clean AC filter" in every room) — one
-- task_templates row per location, grouped for display and bulk creation.
CREATE TABLE IF NOT EXISTS template_groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Recurring task templates. Scheduler spawns a task when next_due arrives. -- Recurring task templates. Scheduler spawns a task when next_due arrives.
CREATE TABLE IF NOT EXISTS task_templates ( CREATE TABLE IF NOT EXISTS task_templates (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@ -88,6 +97,7 @@ export async function initDb() {
template_notes TEXT NOT NULL DEFAULT '', -- carried onto every future occurrence template_notes TEXT NOT NULL DEFAULT '', -- carried onto every future occurrence
active BOOLEAN NOT NULL DEFAULT TRUE, active BOOLEAN NOT NULL DEFAULT TRUE,
schedule_mode TEXT NOT NULL DEFAULT 'fixed', -- fixed (anchored to next_due) | floating (anchored to completion) schedule_mode TEXT NOT NULL DEFAULT 'fixed', -- fixed (anchored to next_due) | floating (anchored to completion)
group_id INT REFERENCES template_groups(id) ON DELETE SET NULL,
created_by TEXT, created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@ -177,6 +187,9 @@ export async function initDb() {
await pool.query(` await pool.query(`
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS pending_task_id INT REFERENCES tasks(id) ON DELETE SET NULL ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS pending_task_id INT REFERENCES tasks(id) ON DELETE SET NULL
`) `)
await pool.query(`
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS group_id INT REFERENCES template_groups(id) ON DELETE SET NULL
`)
await seedDefaults() await seedDefaults()
} }

View file

@ -12,16 +12,36 @@ export async function templateRoutes(app) {
// GET /api/templates — recurring task templates // GET /api/templates — recurring task templates
app.get('/api/templates', { preHandler: requireCap('view') }, async () => { app.get('/api/templates', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT tp.*, l.name AS location_name, a.name AS asset_name, ct.name AS contractor_name `SELECT tp.*, l.name AS location_name, a.name AS asset_name, ct.name AS contractor_name, tg.name AS group_name
FROM task_templates tp FROM task_templates tp
JOIN locations l ON l.id = tp.location_id JOIN locations l ON l.id = tp.location_id
LEFT JOIN assets a ON a.id = tp.asset_id LEFT JOIN assets a ON a.id = tp.asset_id
LEFT JOIN contractors ct ON ct.id = tp.contractor_id LEFT JOIN contractors ct ON ct.id = tp.contractor_id
LEFT JOIN template_groups tg ON tg.id = tp.group_id
ORDER BY tp.active DESC, tp.next_due` ORDER BY tp.active DESC, tp.next_due`
) )
return rows return rows
}) })
// GET /api/template-groups — folders for the Recurring page, and the picker when creating a template
app.get('/api/template-groups', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query('SELECT * FROM template_groups ORDER BY name')
return rows
})
// POST /api/template-groups — upsert by name; the frontend calls this before creating
// a (batch of) template(s) so picking an existing name never creates a duplicate folder.
app.post('/api/template-groups', { preHandler: requireCap('manage_templates') }, async (req, reply) => {
const name = (req.body?.name || '').trim()
if (!name) return reply.status(400).send({ error: 'name required' })
const { rows } = await pool.query(
`INSERT INTO template_groups (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *`,
[name]
)
return rows[0]
})
app.post('/api/templates', { preHandler: requireCap('manage_templates') }, async (req, reply) => { app.post('/api/templates', { preHandler: requireCap('manage_templates') }, async (req, reply) => {
const b = req.body || {} const b = req.body || {}
if (!b.title || !b.location_id || !b.next_due) { if (!b.title || !b.location_id || !b.next_due) {
@ -32,13 +52,17 @@ export async function templateRoutes(app) {
if (b.schedule_mode && !SCHEDULE_MODES.includes(b.schedule_mode)) return reply.status(400).send({ error: 'Invalid schedule_mode' }) if (b.schedule_mode && !SCHEDULE_MODES.includes(b.schedule_mode)) return reply.status(400).send({ error: 'Invalid schedule_mode' })
const interval = parseInt(b.interval_value) || 1 const interval = parseInt(b.interval_value) || 1
if (interval < 1) return reply.status(400).send({ error: 'interval_value must be at least 1' }) if (interval < 1) return reply.status(400).send({ error: 'interval_value must be at least 1' })
if (b.group_id) {
const { rows: g } = await pool.query('SELECT id FROM template_groups WHERE id = $1', [b.group_id])
if (!g.length) return reply.status(400).send({ error: 'Unknown group_id' })
}
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO task_templates `INSERT INTO task_templates
(title, description, location_id, asset_id, priority, unusable, assigned_type, assigned_to, (title, description, location_id, asset_id, priority, unusable, assigned_type, assigned_to,
assigned_to_name, contractor_id, interval_value, interval_unit, next_due, template_notes, assigned_to_name, contractor_id, interval_value, interval_unit, next_due, template_notes,
schedule_mode, created_by) schedule_mode, group_id, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING *`,
[ [
b.title, b.description || null, b.location_id, b.asset_id || null, b.title, b.description || null, b.location_id, b.asset_id || null,
b.priority || 'medium', b.unusable === true, b.priority || 'medium', b.unusable === true,
@ -47,7 +71,7 @@ export async function templateRoutes(app) {
b.assigned_type === 'contractor' ? null : (b.assigned_to_name || null), b.assigned_type === 'contractor' ? null : (b.assigned_to_name || null),
b.assigned_type === 'contractor' ? (b.contractor_id || null) : null, b.assigned_type === 'contractor' ? (b.contractor_id || null) : null,
interval, b.interval_unit || 'months', b.next_due, interval, b.interval_unit || 'months', b.next_due,
b.template_notes || '', b.schedule_mode || 'fixed', req.user.email, b.template_notes || '', b.schedule_mode || 'fixed', b.group_id || null, req.user.email,
] ]
) )
return rows[0] return rows[0]
@ -61,6 +85,10 @@ export async function templateRoutes(app) {
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' }) if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' })
if (b.interval_unit && !INTERVAL_UNITS.includes(b.interval_unit)) return reply.status(400).send({ error: 'Invalid interval_unit' }) if (b.interval_unit && !INTERVAL_UNITS.includes(b.interval_unit)) return reply.status(400).send({ error: 'Invalid interval_unit' })
if (b.schedule_mode && !SCHEDULE_MODES.includes(b.schedule_mode)) return reply.status(400).send({ error: 'Invalid schedule_mode' }) if (b.schedule_mode && !SCHEDULE_MODES.includes(b.schedule_mode)) return reply.status(400).send({ error: 'Invalid schedule_mode' })
if (b.group_id) {
const { rows: g } = await pool.query('SELECT id FROM template_groups WHERE id = $1', [b.group_id])
if (!g.length) return reply.status(400).send({ error: 'Unknown group_id' })
}
const newType = b.assigned_type || t.assigned_type const newType = b.assigned_type || t.assigned_type
const { rows } = await pool.query( const { rows } = await pool.query(
@ -68,8 +96,8 @@ export async function templateRoutes(app) {
title = $1, description = $2, location_id = $3, asset_id = $4, priority = $5, unusable = $6, title = $1, description = $2, location_id = $3, asset_id = $4, priority = $5, unusable = $6,
assigned_type = $7, assigned_to = $8, assigned_to_name = $9, contractor_id = $10, assigned_type = $7, assigned_to = $8, assigned_to_name = $9, contractor_id = $10,
interval_value = $11, interval_unit = $12, next_due = $13, template_notes = $14, active = $15, interval_value = $11, interval_unit = $12, next_due = $13, template_notes = $14, active = $15,
schedule_mode = $16 schedule_mode = $16, group_id = $17
WHERE id = $17 RETURNING *`, WHERE id = $18 RETURNING *`,
[ [
b.title ?? t.title, b.title ?? t.title,
b.description !== undefined ? b.description : t.description, b.description !== undefined ? b.description : t.description,
@ -87,6 +115,7 @@ export async function templateRoutes(app) {
b.template_notes !== undefined ? b.template_notes : t.template_notes, b.template_notes !== undefined ? b.template_notes : t.template_notes,
b.active ?? t.active, b.active ?? t.active,
b.schedule_mode ?? t.schedule_mode, b.schedule_mode ?? t.schedule_mode,
b.group_id !== undefined ? (b.group_id || null) : t.group_id,
req.params.id, req.params.id,
] ]
) )

View file

@ -1,6 +1,6 @@
import type { import type {
Task, TaskDetail, TaskStatus, Location, Category, Asset, AssetDetail, Task, TaskDetail, TaskStatus, Location, Category, Asset, AssetDetail,
Contractor, ContractorDetail, ContractorDoc, Template, AppConfig, AuthUser, TaskPhoto, Contractor, ContractorDetail, ContractorDoc, Template, TemplateGroup, AppConfig, AuthUser, TaskPhoto,
} from './types' } from './types'
const BASE = '/maintenance/api' const BASE = '/maintenance/api'
@ -203,6 +203,15 @@ export function runDueTemplates(): Promise<{ ok: boolean; spawned: number }> {
return request('/templates/run-due', { method: 'POST', body: JSON.stringify({}) }) return request('/templates/run-due', { method: 'POST', body: JSON.stringify({}) })
} }
// Template groups (folders for recurring tasks repeated across many locations)
export function fetchTemplateGroups(): Promise<TemplateGroup[]> {
return request('/template-groups')
}
// Upsert by name — picking an existing name in the UI never creates a duplicate folder.
export function upsertTemplateGroup(name: string): Promise<TemplateGroup> {
return request('/template-groups', { method: 'POST', body: JSON.stringify({ name }) })
}
// Config // Config
export function fetchConfig(): Promise<AppConfig> { export function fetchConfig(): Promise<AppConfig> {
return request('/config') return request('/config')

View file

@ -431,6 +431,11 @@ table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border);
table.data tr:last-child td { border-bottom: none; } table.data tr:last-child td { border-bottom: none; }
table.data tr.clickable { cursor: pointer; } table.data tr.clickable { cursor: pointer; }
table.data tr.clickable:hover td { background: var(--body-bg); } table.data tr.clickable:hover td { background: var(--body-bg); }
table.data tr.group-row { cursor: pointer; background: var(--body-bg); }
table.data tr.group-row td { font-weight: 600; display: flex; align-items: center; gap: 6px; }
table.data tr.group-row:hover td { background: var(--card-border); }
.group-row-chevron { transition: transform .15s; }
.group-row-chevron.open { transform: rotate(90deg); }
/* ── Stats strip ───────────────────────────────────────────── */ /* ── Stats strip ───────────────────────────────────────────── */
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } .stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }

View file

@ -1,8 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X, Play } from 'lucide-react' import { Plus, X, Play, Copy, ChevronRight } from 'lucide-react'
import type { Template, Category, Location, Asset, Priority, ScheduleMode } from '../types' import type { Template, TemplateGroup, Category, Location, Asset, Priority, ScheduleMode } from '../types'
import { PRIORITIES, PRIORITY_LABELS, can } from '../types' import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
import { fetchTemplates, createTemplate, updateTemplate, runDueTemplates, fetchLocations, fetchAssets } from '../api' import {
fetchTemplates, createTemplate, updateTemplate, runDueTemplates, fetchLocations, fetchAssets,
fetchTemplateGroups, upsertTemplateGroup,
} from '../api'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
import AssigneeSelect, { type Assignment } from '../components/AssigneeSelect' import AssigneeSelect, { type Assignment } from '../components/AssigneeSelect'
import { PriorityBadge, formatDate } from '../components/shared' import { PriorityBadge, formatDate } from '../components/shared'
@ -21,23 +24,30 @@ interface TemplateForm {
template_notes: string template_notes: string
active: boolean active: boolean
schedule_mode: ScheduleMode schedule_mode: ScheduleMode
group_name: string
assignment: Assignment assignment: Assignment
// Bulk creation only — never sent as-is, resolved into one row per location.
multiLocation: boolean
location_ids: number[]
} }
const EMPTY: TemplateForm = { const EMPTY: TemplateForm = {
title: '', description: '', location_id: '', asset_id: '', priority: 'medium', unusable: false, title: '', description: '', location_id: '', asset_id: '', priority: 'medium', unusable: false,
interval_value: 1, interval_unit: 'months', next_due: '', template_notes: '', active: true, interval_value: 1, interval_unit: 'months', next_due: '', template_notes: '', active: true,
schedule_mode: 'fixed', schedule_mode: 'fixed', group_name: '',
assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null }, assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null },
multiLocation: false, location_ids: [],
} }
export default function Recurring() { export default function Recurring() {
const { user } = useAuth() const { user } = useAuth()
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
const [groups, setGroups] = useState<TemplateGroup[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [locations, setLocations] = useState<Location[]>([]) const [locations, setLocations] = useState<Location[]>([])
const [assets, setAssets] = useState<Asset[]>([]) const [assets, setAssets] = useState<Asset[]>([])
const [form, setForm] = useState<TemplateForm | null>(null) const [form, setForm] = useState<TemplateForm | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null) const [info, setInfo] = useState<string | null>(null)
@ -45,6 +55,7 @@ export default function Recurring() {
const load = useCallback(() => { const load = useCallback(() => {
fetchTemplates().then(setTemplates).catch(err => setError(err.message)) fetchTemplates().then(setTemplates).catch(err => setError(err.message))
fetchTemplateGroups().then(setGroups).catch(() => {})
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { useEffect(() => {
@ -52,7 +63,7 @@ export default function Recurring() {
fetchAssets().then(setAssets).catch(() => {}) fetchAssets().then(setAssets).catch(() => {})
}, []) }, [])
const grouped = useMemo(() => categories.map(c => ({ const locationGroups = useMemo(() => categories.map(c => ({
category: c, category: c,
locations: locations.filter(l => l.category_id === c.id), locations: locations.filter(l => l.category_id === c.id),
})).filter(g => g.locations.length), [categories, locations]) })).filter(g => g.locations.length), [categories, locations])
@ -62,17 +73,95 @@ export default function Recurring() {
[assets, 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), 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: '', 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() { async function save() {
if (!form || !form.title.trim() || !form.location_id || !form.next_due) { if (!form || !form.title.trim() || !form.next_due) {
setError('Title, location and first due date are required') setError('Title and first due date are required')
return 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 { try {
const body = { const groupId = form.group_name.trim() ? (await upsertTemplateGroup(form.group_name.trim())).id : null
const base = {
title: form.title.trim(), title: form.title.trim(),
description: form.description || null, description: form.description || null,
location_id: form.location_id,
asset_id: form.asset_id || null,
priority: form.priority, priority: form.priority,
unusable: form.unusable, unusable: form.unusable,
interval_value: form.interval_value, interval_value: form.interval_value,
@ -81,13 +170,23 @@ export default function Recurring() {
template_notes: form.template_notes, template_notes: form.template_notes,
active: form.active, active: form.active,
schedule_mode: form.schedule_mode, schedule_mode: form.schedule_mode,
group_id: groupId,
assigned_type: form.assignment.assigned_type, assigned_type: form.assignment.assigned_type,
assigned_to: form.assignment.assigned_to, assigned_to: form.assignment.assigned_to,
assigned_to_name: form.assignment.assigned_to_name, assigned_to_name: form.assignment.assigned_to_name,
contractor_id: form.assignment.contractor_id, 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) if (form.id) await updateTemplate(form.id, body)
else await createTemplate(body) else await createTemplate(body)
}
setForm(null) setForm(null)
setError(null) setError(null)
load() load()
@ -96,6 +195,32 @@ export default function Recurring() {
} }
} }
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)}
</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 ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
@ -118,37 +243,27 @@ export default function Recurring() {
<div className="table-wrap"> <div className="table-wrap">
<table className="data"> <table className="data">
<thead> <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> <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> </thead>
<tbody> <tbody>
{templates.map(t => ( {groupSections.map(g => {
<tr key={t.id} className={canManage ? 'clickable' : ''} onClick={() => canManage && setForm({ const isOpen = !collapsedGroups.has(g.name)
id: t.id, title: t.title, description: t.description || '', return (
location_id: t.location_id, asset_id: t.asset_id || '', priority: t.priority, <Fragment key={g.name}>
unusable: t.unusable, interval_value: t.interval_value, interval_unit: t.interval_unit, <tr className="group-row" onClick={() => toggleGroup(g.name)}>
next_due: t.next_due.slice(0, 10), template_notes: t.template_notes, active: t.active, <td colSpan={9}>
schedule_mode: t.schedule_mode, <ChevronRight size={14} strokeWidth={1.75} className={`group-row-chevron${isOpen ? ' open' : ''}`} />
assignment: { {g.name}
assigned_type: t.assigned_type, assigned_to: t.assigned_to, <span className="badge badge-outline">{g.templates.length} location{g.templates.length === 1 ? '' : 's'}</span>
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}{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)}
</td> </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> </tr>
))} {isOpen && g.templates.map(renderRow)}
</Fragment>
)
})}
{ungrouped.map(renderRow)}
{templates.length === 0 && ( {templates.length === 0 && (
<tr><td colSpan={8} className="empty-state">No recurring templates — set up fire alarm tests, boiler service, legionella flushing…</td></tr> <tr><td colSpan={9} className="empty-state">No recurring templates — set up fire alarm tests, boiler service, legionella flushing…</td></tr>
)} )}
</tbody> </tbody>
</table> </table>
@ -165,10 +280,50 @@ export default function Recurring() {
<div className="field"><label>Title</label> <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 /> <input type="text" value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="e.g. Fire alarm weekly test" autoFocus />
</div> </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> <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: '' })}> <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> <option value="">Select location…</option>
{grouped.map(g => ( {locationGroups.map(g => (
<optgroup key={g.category.id} label={g.category.name}> <optgroup key={g.category.id} label={g.category.name}>
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)} {g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</optgroup> </optgroup>
@ -183,6 +338,9 @@ export default function Recurring() {
</select> </select>
</div> </div>
)} )}
</>
)}
<div className="field"><label>Description</label> <div className="field"><label>Description</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} /> <textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div> </div>

View file

@ -209,6 +209,13 @@ export interface Template {
active: boolean active: boolean
schedule_mode: ScheduleMode schedule_mode: ScheduleMode
pending_task_id: number | null pending_task_id: number | null
group_id: number | null
group_name: string | null
}
export interface TemplateGroup {
id: number
name: string
} }
export interface AppConfig { export interface AppConfig {