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()
);
-- 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()
}

View file

@ -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,
]
)