Merge asset recurring templates into the main Recurring/Summary pages

Assets.tsx had its own bare-bones template-creation form (title/interval/
next_due only, no description/priority-choice/assignee/schedule_mode/
lead_days/group) with zero edit or delete path — a linked recurring task
could only ever be created from here, never managed. The backend already
sourced the asset detail's tasks/templates straight from the main tasks
and task_templates tables (no separate storage), so the fix is entirely
frontend: stop duplicating the editor, link back to the real one.

- Assets.tsx: removed the bespoke ServiceTaskForm entirely. Each linked
  template row now navigates to /recurring?edit=<id> (opens that
  template's full edit form there, including delete). "+ Add" navigates
  to /recurring?new_asset=&new_location= for a prefilled New Template
  form. Added a matching "+ Report fault" action navigating to
  /summary?new_asset=&new_location= for one-off tasks, since there was
  previously no way to create a plain task from the Assets page at all.
  Existing tasks still open in-place via TaskModal, unchanged.
- Recurring.tsx / Summary.tsx: read those query params on mount, open
  the appropriate form/modal prefilled, then strip the params from the
  URL (replace, no history pollution).
- NewTaskModal.tsx + Recurring.tsx template form: asset picker is no
  longer gated behind picking a location first — it always shows the
  full register (grouped by location) or the location-filtered list
  once one's chosen, and selecting an asset now back-fills its location
  automatically. Makes tagging routine servicing to a specific asset
  (fixed or floating recurring) the natural way in, for both one-off
  and recurring tasks.
- Asset list/detail counts (open tasks, recurring templates) are
  unchanged — still sourced from the same main-table queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-26 15:09:24 +00:00
parent d9b01a5981
commit 2d2541576d
4 changed files with 136 additions and 84 deletions

View file

@ -7,17 +7,19 @@ import AssigneeSelect, { type Assignment } from './AssigneeSelect'
import SearchSelect from './SearchSelect' import SearchSelect from './SearchSelect'
import { PriorityBadge, StatusBadge } from './shared' import { PriorityBadge, StatusBadge } from './shared'
export default function NewTaskModal({ categories, locations, config, onClose, onCreated }: { export default function NewTaskModal({ categories, locations, config, onClose, onCreated, initialLocationId, initialAssetId }: {
categories: Category[] categories: Category[]
locations: Location[] locations: Location[]
config: AppConfig | null config: AppConfig | null
onClose: () => void onClose: () => void
onCreated: () => void onCreated: () => void
initialLocationId?: number
initialAssetId?: number
}) { }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [locationId, setLocationId] = useState<number | ''>('') const [locationId, setLocationId] = useState<number | ''>(initialLocationId ?? '')
const [assetId, setAssetId] = useState<number | ''>('') const [assetId, setAssetId] = useState<number | ''>(initialAssetId ?? '')
const [priority, setPriority] = useState<Priority>('medium') const [priority, setPriority] = useState<Priority>('medium')
const [unusable, setUnusable] = useState(false) const [unusable, setUnusable] = useState(false)
const [dueDate, setDueDate] = useState('') const [dueDate, setDueDate] = useState('')
@ -40,6 +42,27 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
[assets, locationId] [assets, locationId]
) )
// Asset picker: filtered to the chosen location once one's picked, otherwise the
// full register grouped by location — so an asset can be picked first, and doing
// so fills in its location automatically.
const assetGroups = useMemo(() => {
const pool = locationId ? locationAssets : assets
const byLocation = new Map<string, Asset[]>()
for (const a of pool) {
if (!byLocation.has(a.location_name)) byLocation.set(a.location_name, [])
byLocation.get(a.location_name)!.push(a)
}
return [...byLocation.entries()].sort(([a], [b]) => a.localeCompare(b))
}, [assets, locationAssets, locationId])
function pickAsset(id: number | '') {
setAssetId(id)
if (id) {
const a = assets.find(x => x.id === id)
if (a) setLocationId(a.location_id)
}
}
useEffect(() => { fetchAssets().then(setAssets).catch(() => {}) }, []) useEffect(() => { fetchAssets().then(setAssets).catch(() => {}) }, [])
// Duplicate hint: existing open tasks at the chosen location // Duplicate hint: existing open tasks at the chosen location
@ -127,13 +150,18 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
</div> </div>
)} )}
{locationAssets.length > 0 && ( {assets.length > 0 && (
<div className="field"> <div className="field">
<label>Asset (optional)</label> <label>Asset (optional)</label>
<select value={assetId} onChange={e => setAssetId(e.target.value ? parseInt(e.target.value) : '')}> <select value={assetId} onChange={e => pickAsset(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">None</option> <option value="">None</option>
{locationAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)} {assetGroups.map(([locName, as]) => (
<optgroup key={locName} label={locName}>
{as.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</optgroup>
))}
</select> </select>
{!locationId && <div className="field-hint">Picking an asset fills in its location automatically.</div>}
</div> </div>
)} )}

View file

@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Pencil, Plus, X } from 'lucide-react' import { Pencil, Plus, X } from 'lucide-react'
import type { Asset, AssetDetail, Location } from '../types' import type { Asset, AssetDetail, Location } from '../types'
import { can } from '../types' import { can } from '../types'
import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations, createTemplate } from '../api' import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations } from '../api'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
import SearchSelect from '../components/SearchSelect' import SearchSelect from '../components/SearchSelect'
import TaskModal from '../components/TaskModal' import TaskModal from '../components/TaskModal'
@ -20,13 +21,6 @@ interface AssetForm {
active: boolean active: boolean
} }
interface ServiceTaskForm {
title: string
interval_value: string
interval_unit: 'days' | 'weeks' | 'months'
next_due: string
}
const EMPTY: AssetForm = { const EMPTY: AssetForm = {
name: '', location_id: '', make_model: '', serial_no: '', name: '', location_id: '', make_model: '', serial_no: '',
install_date: '', install_date_unknown: false, notes: '', active: true, install_date: '', install_date_unknown: false, notes: '', active: true,
@ -34,16 +28,17 @@ const EMPTY: AssetForm = {
export default function Assets() { export default function Assets() {
const { user } = useAuth() const { user } = useAuth()
const navigate = useNavigate()
const [assets, setAssets] = useState<Asset[]>([]) const [assets, setAssets] = useState<Asset[]>([])
const [locations, setLocations] = useState<Location[]>([]) const [locations, setLocations] = useState<Location[]>([])
const [detail, setDetail] = useState<AssetDetail | null>(null) const [detail, setDetail] = useState<AssetDetail | null>(null)
const [form, setForm] = useState<AssetForm | null>(null) const [form, setForm] = useState<AssetForm | null>(null)
const [serviceForm, setServiceForm] = useState<ServiceTaskForm | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(null) const [openTask, setOpenTask] = useState<number | null>(null)
const canManage = can(user, 'manage_assets') const canManage = can(user, 'manage_assets')
const canManageTemplates = can(user, 'manage_templates') const canManageTemplates = can(user, 'manage_templates')
const canReport = can(user, 'report')
const locationOptions = useMemo(() => const locationOptions = useMemo(() =>
locations.map(l => ({ value: l.id, label: l.name, group: l.category_name })), locations.map(l => ({ value: l.id, label: l.name, group: l.category_name })),
@ -78,30 +73,6 @@ export default function Assets() {
} }
} }
async function saveServiceTask(assetId: number, locationId: number) {
if (!serviceForm || !serviceForm.title.trim() || !serviceForm.next_due) {
setError('Title and next due date required')
return
}
try {
await createTemplate({
title: serviceForm.title.trim(),
location_id: locationId,
asset_id: assetId,
interval_value: parseInt(serviceForm.interval_value) || 1,
interval_unit: serviceForm.interval_unit,
next_due: serviceForm.next_due,
priority: 'medium',
})
setServiceForm(null)
setError(null)
// Refresh detail to show new template
fetchAsset(assetId).then(setDetail).catch(() => {})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create service task')
}
}
return ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
@ -174,61 +145,35 @@ export default function Assets() {
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Recurring service tasks Recurring service tasks
{canManageTemplates && !serviceForm && ( {canManageTemplates && (
<button className="btn btn-sm" onClick={() => setServiceForm({ title: '', interval_value: '1', interval_unit: 'months', next_due: '' })}> <button className="btn btn-sm" onClick={() => navigate(`/recurring?new_asset=${detail.id}&new_location=${detail.location_id}`)}>
<Plus size={12} strokeWidth={1.75} /> Add <Plus size={12} strokeWidth={1.75} /> Add
</button> </button>
)} )}
</div> </div>
{detail.templates.map(tp => ( {detail.templates.map(tp => (
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}> <div
key={tp.id} className="task-card-meta clickable" style={{ marginBottom: 4, cursor: 'pointer' }}
onClick={() => navigate(`/recurring?edit=${tp.id}`)}
>
<span>{tp.title}</span> <span>{tp.title}</span>
<span>every {tp.interval_value} {tp.interval_unit}</span> <span>every {tp.interval_value} {tp.interval_unit}</span>
<span>next due {formatDate(tp.next_due)}</span> <span>next due {formatDate(tp.next_due)}</span>
{!tp.active && <span className="badge badge-outline">paused</span>} {!tp.active && <span className="badge badge-outline">paused</span>}
</div> </div>
))} ))}
{detail.templates.length === 0 && !serviceForm && ( {detail.templates.length === 0 && (
<div className="muted" style={{ fontSize: 12.5 }}>No recurring service tasks for this asset.</div> <div className="muted" style={{ fontSize: 12.5 }}>No recurring service tasks for this asset.</div>
)} )}
{serviceForm && (
<div className="card" style={{ marginTop: 8, padding: 12 }}>
<div className="field" style={{ marginBottom: 8 }}>
<label>Task title</label>
<input type="text" value={serviceForm.title} autoFocus
onChange={e => setServiceForm({ ...serviceForm, title: e.target.value })}
placeholder="e.g. Annual boiler service" />
</div>
<div className="field-row" style={{ marginBottom: 8 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>Every</label>
<input type="number" min={1} value={serviceForm.interval_value}
onChange={e => setServiceForm({ ...serviceForm, interval_value: e.target.value })}
style={{ width: 64 }} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Unit</label>
<select value={serviceForm.interval_unit}
onChange={e => setServiceForm({ ...serviceForm, interval_unit: e.target.value as ServiceTaskForm['interval_unit'] })}>
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Next due</label>
<input type="date" value={serviceForm.next_due}
onChange={e => setServiceForm({ ...serviceForm, next_due: e.target.value })} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm" onClick={() => setServiceForm(null)}>Cancel</button>
<button className="btn btn-sm btn-primary" onClick={() => saveServiceTask(detail.id, detail.location_id)}>Save</button>
</div>
</div>
)}
<div className="section-title">Task history</div> <div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Task history
{canReport && (
<button className="btn btn-sm" onClick={() => navigate(`/summary?new_asset=${detail.id}&new_location=${detail.location_id}`)}>
<Plus size={12} strokeWidth={1.75} /> Report fault
</button>
)}
</div>
{detail.tasks.length === 0 && <div className="muted">No tasks logged against this asset.</div>} {detail.tasks.length === 0 && <div className="muted">No tasks logged against this asset.</div>}
{detail.tasks.map(t => ( {detail.tasks.map(t => (
<div key={t.id} className="timeline-item clickable" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}> <div key={t.id} className="timeline-item clickable" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>

View file

@ -1,4 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react' import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Plus, X, Play, Copy, ChevronRight, Trash2 } from 'lucide-react' import { Plus, X, Play, Copy, ChevronRight, Trash2 } from 'lucide-react'
import type { Template, TemplateGroup, 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'
@ -42,6 +43,7 @@ const EMPTY: TemplateForm = {
export default function Recurring() { export default function Recurring() {
const { user } = useAuth() const { user } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
const [groups, setGroups] = useState<TemplateGroup[]>([]) const [groups, setGroups] = useState<TemplateGroup[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
@ -64,6 +66,30 @@ export default function Recurring() {
fetchAssets().then(setAssets).catch(() => {}) fetchAssets().then(setAssets).catch(() => {})
}, []) }, [])
// Deep links from the Assets page: ?edit=<templateId> opens that template's
// edit form (waits for templates to load if it fires before they arrive);
// ?new_asset=&new_location= opens a prefilled New Template form.
useEffect(() => {
const editId = searchParams.get('edit')
const newAsset = searchParams.get('new_asset')
const newLocation = searchParams.get('new_location')
if (editId) {
const t = templates.find(x => x.id === parseInt(editId))
if (t) {
openEdit(t)
setSearchParams(prev => { const next = new URLSearchParams(prev); next.delete('edit'); return next }, { replace: true })
}
} else if (newAsset || newLocation) {
setForm({ ...EMPTY, asset_id: newAsset ? parseInt(newAsset) : '', location_id: newLocation ? parseInt(newLocation) : '' })
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('new_asset'); next.delete('new_location')
return next
}, { replace: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templates, searchParams])
const locationGroups = 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),
@ -74,6 +100,29 @@ export default function Recurring() {
[assets, form?.location_id] [assets, form?.location_id]
) )
// Asset picker for the single-location form: filtered to the chosen location
// once one's picked, otherwise the full register grouped by location — so an
// asset can be picked first, and doing so fills in its location automatically.
const formAssetGroups = useMemo(() => {
const pool = form?.location_id ? formAssets : assets
const byLocation = new Map<string, Asset[]>()
for (const a of pool) {
if (!byLocation.has(a.location_name)) byLocation.set(a.location_name, [])
byLocation.get(a.location_name)!.push(a)
}
return [...byLocation.entries()].sort(([a], [b]) => a.localeCompare(b))
}, [assets, formAssets, form?.location_id])
function pickFormAsset(id: number | '') {
if (!form) return
if (id) {
const a = assets.find(x => x.id === id)
setForm({ ...form, asset_id: id, location_id: a ? a.location_id : form.location_id })
} else {
setForm({ ...form, asset_id: '' })
}
}
const { groupSections, ungrouped } = useMemo(() => { const { groupSections, ungrouped } = useMemo(() => {
const byGroup = new Map<string, Template[]>() const byGroup = new Map<string, Template[]>()
const solo: Template[] = [] const solo: Template[] = []
@ -344,12 +393,17 @@ export default function Recurring() {
))} ))}
</select> </select>
</div> </div>
{formAssets.length > 0 && ( {assets.length > 0 && (
<div className="field"><label>Asset (optional)</label> <div className="field"><label>Asset (optional)</label>
<select value={form.asset_id} onChange={e => setForm({ ...form, asset_id: e.target.value ? parseInt(e.target.value) : '' })}> <select value={form.asset_id} onChange={e => pickFormAsset(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">None</option> <option value="">None</option>
{formAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)} {formAssetGroups.map(([locName, as]) => (
<optgroup key={locName} label={locName}>
{as.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</optgroup>
))}
</select> </select>
{!form.location_id && <div className="field-hint">Picking an asset fills in its location automatically.</div>}
</div> </div>
)} )}
</> </>

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Plus, RefreshCw, Camera, BedDouble, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import { Plus, RefreshCw, Camera, BedDouble, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types' import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types'
import { STATUS_LABELS, can } from '../types' import { STATUS_LABELS, can } from '../types'
@ -47,7 +48,9 @@ export default function Summary() {
setSortMode(prev => SORT_CYCLE[(SORT_CYCLE.indexOf(prev) + 1) % SORT_CYCLE.length]) setSortMode(prev => SORT_CYCLE[(SORT_CYCLE.indexOf(prev) + 1) % SORT_CYCLE.length])
} }
const [searchParams, setSearchParams] = useSearchParams()
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const [newTaskPreset, setNewTaskPreset] = useState<{ locationId?: number; assetId?: number }>({})
const [openTask, setOpenTask] = useState<number | null>(null) const [openTask, setOpenTask] = useState<number | null>(null)
const roomsCat = useMemo(() => categories.find(c => c.is_rooms) ?? null, [categories]) const roomsCat = useMemo(() => categories.find(c => c.is_rooms) ?? null, [categories])
@ -80,6 +83,26 @@ export default function Summary() {
fetchConfig().then(setConfig).catch(() => {}) fetchConfig().then(setConfig).catch(() => {})
}, []) }, [])
// Deep link from the Assets page: ?new_asset=&new_location= opens a prefilled
// Report fault form instead of the asset page having its own creation form.
useEffect(() => {
const newAsset = searchParams.get('new_asset')
const newLocation = searchParams.get('new_location')
if (newAsset || newLocation) {
setNewTaskPreset({
locationId: newLocation ? parseInt(newLocation) : undefined,
assetId: newAsset ? parseInt(newAsset) : undefined,
})
setShowNew(true)
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('new_asset'); next.delete('new_location')
return next
}, { replace: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
const counts = useMemo(() => { const counts = useMemo(() => {
const c: Partial<Record<TaskStatus, number>> = {} const c: Partial<Record<TaskStatus, number>> = {}
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1 for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1
@ -141,7 +164,7 @@ export default function Summary() {
<RefreshCw size={14} strokeWidth={1.75} /> Refresh <RefreshCw size={14} strokeWidth={1.75} /> Refresh
</button> </button>
{can(user, 'report') && ( {can(user, 'report') && (
<button className="btn btn-primary" onClick={() => setShowNew(true)}> <button className="btn btn-primary" onClick={() => { setNewTaskPreset({}); setShowNew(true) }}>
<Plus size={14} strokeWidth={1.75} /> Report fault <Plus size={14} strokeWidth={1.75} /> Report fault
</button> </button>
)} )}
@ -287,6 +310,8 @@ export default function Summary() {
categories={categories} categories={categories}
locations={locations} locations={locations}
config={config} config={config}
initialLocationId={newTaskPreset.locationId}
initialAssetId={newTaskPreset.assetId}
onClose={() => setShowNew(false)} onClose={() => setShowNew(false)}
onCreated={load} onCreated={load}
/> />