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:
parent
c740b87f1b
commit
ee23cb1b16
6 changed files with 285 additions and 64 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue