From ee23cb1b16afd145e5a83927381c74c236932e3a Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 26 Aug 2026 10:33:19 +0000 Subject: [PATCH] Add template groups: bulk multi-location creation + duplicate action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/db.js | 13 ++ backend/src/routes/templates.js | 41 ++++- frontend/src/api.ts | 11 +- frontend/src/index.css | 5 + frontend/src/pages/Recurring.tsx | 272 ++++++++++++++++++++++++------- frontend/src/types.ts | 7 + 6 files changed, 285 insertions(+), 64 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 1c3725e..e005457 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -69,6 +69,15 @@ export async function initDb() { 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. CREATE TABLE IF NOT EXISTS task_templates ( id SERIAL PRIMARY KEY, @@ -88,6 +97,7 @@ export async function initDb() { template_notes TEXT NOT NULL DEFAULT '', -- carried onto every future occurrence active BOOLEAN NOT NULL DEFAULT TRUE, 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_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -177,6 +187,9 @@ export async function initDb() { await pool.query(` 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() } diff --git a/backend/src/routes/templates.js b/backend/src/routes/templates.js index dc233be..fca871a 100644 --- a/backend/src/routes/templates.js +++ b/backend/src/routes/templates.js @@ -12,16 +12,36 @@ export async function templateRoutes(app) { // GET /api/templates — recurring task templates app.get('/api/templates', { preHandler: requireCap('view') }, async () => { 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 JOIN locations l ON l.id = tp.location_id LEFT JOIN assets a ON a.id = tp.asset_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` ) 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) => { const b = req.body || {} 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' }) const interval = parseInt(b.interval_value) || 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( `INSERT INTO task_templates (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, - schedule_mode, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING *`, + schedule_mode, group_id, created_by) + 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.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' ? (b.contractor_id || null) : null, 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] @@ -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.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.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 { 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, 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, - schedule_mode = $16 - WHERE id = $17 RETURNING *`, + schedule_mode = $16, group_id = $17 + WHERE id = $18 RETURNING *`, [ b.title ?? t.title, 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.active ?? t.active, b.schedule_mode ?? t.schedule_mode, + b.group_id !== undefined ? (b.group_id || null) : t.group_id, req.params.id, ] ) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9e61531..721d1a5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,6 @@ import type { Task, TaskDetail, TaskStatus, Location, Category, Asset, AssetDetail, - Contractor, ContractorDetail, ContractorDoc, Template, AppConfig, AuthUser, TaskPhoto, + Contractor, ContractorDetail, ContractorDoc, Template, TemplateGroup, AppConfig, AuthUser, TaskPhoto, } from './types' 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({}) }) } +// Template groups (folders for recurring tasks repeated across many locations) +export function fetchTemplateGroups(): Promise { + 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 { + return request('/template-groups', { method: 'POST', body: JSON.stringify({ name }) }) +} + // Config export function fetchConfig(): Promise { return request('/config') diff --git a/frontend/src/index.css b/frontend/src/index.css index a3d9a26..cde680a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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.clickable { cursor: pointer; } 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 { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } diff --git a/frontend/src/pages/Recurring.tsx b/frontend/src/pages/Recurring.tsx index d0f0aa7..cc50644 100644 --- a/frontend/src/pages/Recurring.tsx +++ b/frontend/src/pages/Recurring.tsx @@ -1,8 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { Plus, X, Play } from 'lucide-react' -import type { Template, Category, Location, Asset, Priority, ScheduleMode } from '../types' +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react' +import { Plus, X, Play, Copy, ChevronRight } 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, runDueTemplates, fetchLocations, fetchAssets } from '../api' +import { + fetchTemplates, createTemplate, updateTemplate, 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' @@ -21,23 +24,30 @@ interface TemplateForm { 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: '', 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 }, + 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) @@ -45,6 +55,7 @@ export default function Recurring() { const load = useCallback(() => { fetchTemplates().then(setTemplates).catch(err => setError(err.message)) + fetchTemplateGroups().then(setGroups).catch(() => {}) }, []) useEffect(() => { load() }, [load]) useEffect(() => { @@ -52,7 +63,7 @@ export default function Recurring() { fetchAssets().then(setAssets).catch(() => {}) }, []) - const grouped = useMemo(() => categories.map(c => ({ + const locationGroups = useMemo(() => categories.map(c => ({ category: c, locations: locations.filter(l => l.category_id === c.id), })).filter(g => g.locations.length), [categories, locations]) @@ -62,17 +73,95 @@ export default function Recurring() { [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), 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() { - if (!form || !form.title.trim() || !form.location_id || !form.next_due) { - setError('Title, location and first due date are required') + if (!form || !form.title.trim() || !form.next_due) { + setError('Title and first due date are 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 body = { + const groupId = form.group_name.trim() ? (await upsertTemplateGroup(form.group_name.trim())).id : null + const base = { 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, @@ -81,13 +170,23 @@ export default function Recurring() { 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.id) await updateTemplate(form.id, body) - else await createTemplate(body) + + 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() @@ -96,6 +195,32 @@ export default function Recurring() { } } + 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.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')} + {!t.active && paused} + + {canManage && ( + + )} + + + ) + } + return (
@@ -118,37 +243,27 @@ export default function Recurring() {
- + - {templates.map(t => ( - 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, - schedule_mode: t.schedule_mode, - assignment: { - assigned_type: t.assigned_type, assigned_to: t.assigned_to, - assigned_to_name: t.assigned_to_name, contractor_id: t.contractor_id, - }, - })}> - - - - - - - - - - ))} + {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
TaskLocationAssetPriorityEveryNext dueAllocated
{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.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}{!t.active && paused}
+ + {g.name} + {g.templates.length} location{g.templates.length === 1 ? '' : 's'} +
No recurring templates — set up fire alarm tests, boiler service, legionella flushing…
No recurring templates — set up fire alarm tests, boiler service, legionella flushing…
@@ -165,24 +280,67 @@ export default function Recurring() {
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.
- {formAssets.length > 0 && ( -
- -
+ + {!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 && ( +
+ +
+ )} + + )} +