From 2d2541576db4001bc0a9d1f136431fb01e053e8f Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 26 Aug 2026 15:09:24 +0000 Subject: [PATCH] Merge asset recurring templates into the main Recurring/Summary pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= (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 --- frontend/src/components/NewTaskModal.tsx | 40 ++++++++-- frontend/src/pages/Assets.tsx | 93 +++++------------------- frontend/src/pages/Recurring.tsx | 60 ++++++++++++++- frontend/src/pages/Summary.tsx | 27 ++++++- 4 files changed, 136 insertions(+), 84 deletions(-) diff --git a/frontend/src/components/NewTaskModal.tsx b/frontend/src/components/NewTaskModal.tsx index d702004..29ee845 100644 --- a/frontend/src/components/NewTaskModal.tsx +++ b/frontend/src/components/NewTaskModal.tsx @@ -7,17 +7,19 @@ import AssigneeSelect, { type Assignment } from './AssigneeSelect' import SearchSelect from './SearchSelect' 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[] locations: Location[] config: AppConfig | null onClose: () => void onCreated: () => void + initialLocationId?: number + initialAssetId?: number }) { const [title, setTitle] = useState('') const [description, setDescription] = useState('') - const [locationId, setLocationId] = useState('') - const [assetId, setAssetId] = useState('') + const [locationId, setLocationId] = useState(initialLocationId ?? '') + const [assetId, setAssetId] = useState(initialAssetId ?? '') const [priority, setPriority] = useState('medium') const [unusable, setUnusable] = useState(false) const [dueDate, setDueDate] = useState('') @@ -40,6 +42,27 @@ export default function NewTaskModal({ categories, locations, config, onClose, o [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() + 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(() => {}) }, []) // Duplicate hint: existing open tasks at the chosen location @@ -127,13 +150,18 @@ export default function NewTaskModal({ categories, locations, config, onClose, o )} - {locationAssets.length > 0 && ( + {assets.length > 0 && (
- pickAsset(e.target.value ? parseInt(e.target.value) : '')}> - {locationAssets.map(a => )} + {assetGroups.map(([locName, as]) => ( + + {as.map(a => )} + + ))} + {!locationId &&
Picking an asset fills in its location automatically.
}
)} diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx index 1438fe1..390839f 100644 --- a/frontend/src/pages/Assets.tsx +++ b/frontend/src/pages/Assets.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { Pencil, Plus, X } from 'lucide-react' import type { Asset, AssetDetail, Location } 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 SearchSelect from '../components/SearchSelect' import TaskModal from '../components/TaskModal' @@ -20,13 +21,6 @@ interface AssetForm { active: boolean } -interface ServiceTaskForm { - title: string - interval_value: string - interval_unit: 'days' | 'weeks' | 'months' - next_due: string -} - const EMPTY: AssetForm = { name: '', location_id: '', make_model: '', serial_no: '', install_date: '', install_date_unknown: false, notes: '', active: true, @@ -34,16 +28,17 @@ const EMPTY: AssetForm = { export default function Assets() { const { user } = useAuth() + const navigate = useNavigate() const [assets, setAssets] = useState([]) const [locations, setLocations] = useState([]) const [detail, setDetail] = useState(null) const [form, setForm] = useState(null) - const [serviceForm, setServiceForm] = useState(null) const [error, setError] = useState(null) const [openTask, setOpenTask] = useState(null) const canManage = can(user, 'manage_assets') const canManageTemplates = can(user, 'manage_templates') + const canReport = can(user, 'report') const locationOptions = useMemo(() => 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 (
@@ -174,61 +145,35 @@ export default function Assets() {
Recurring service tasks - {canManageTemplates && !serviceForm && ( - )}
{detail.templates.map(tp => ( -
+
navigate(`/recurring?edit=${tp.id}`)} + > {tp.title} every {tp.interval_value} {tp.interval_unit} next due {formatDate(tp.next_due)} {!tp.active && paused}
))} - {detail.templates.length === 0 && !serviceForm && ( + {detail.templates.length === 0 && (
No recurring service tasks for this asset.
)} - {serviceForm && ( -
-
- - setServiceForm({ ...serviceForm, title: e.target.value })} - placeholder="e.g. Annual boiler service" /> -
-
-
- - setServiceForm({ ...serviceForm, interval_value: e.target.value })} - style={{ width: 64 }} /> -
-
- - -
-
- - setServiceForm({ ...serviceForm, next_due: e.target.value })} /> -
-
-
- - -
-
- )} -
Task history
+
+ Task history + {canReport && ( + + )} +
{detail.tasks.length === 0 &&
No tasks logged against this asset.
} {detail.tasks.map(t => (
setOpenTask(t.id)}> diff --git a/frontend/src/pages/Recurring.tsx b/frontend/src/pages/Recurring.tsx index f4f98d1..65f25b8 100644 --- a/frontend/src/pages/Recurring.tsx +++ b/frontend/src/pages/Recurring.tsx @@ -1,4 +1,5 @@ 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 type { Template, TemplateGroup, Category, Location, Asset, Priority, ScheduleMode } from '../types' import { PRIORITIES, PRIORITY_LABELS, can } from '../types' @@ -42,6 +43,7 @@ const EMPTY: TemplateForm = { export default function Recurring() { const { user } = useAuth() + const [searchParams, setSearchParams] = useSearchParams() const [templates, setTemplates] = useState([]) const [groups, setGroups] = useState([]) const [categories, setCategories] = useState([]) @@ -64,6 +66,30 @@ export default function Recurring() { fetchAssets().then(setAssets).catch(() => {}) }, []) + // Deep links from the Assets page: ?edit= 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 => ({ category: c, locations: locations.filter(l => l.category_id === c.id), @@ -74,6 +100,29 @@ export default function Recurring() { [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() + 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 byGroup = new Map() const solo: Template[] = [] @@ -344,12 +393,17 @@ export default function Recurring() { ))}
- {formAssets.length > 0 && ( + {assets.length > 0 && (
- pickFormAsset(e.target.value ? parseInt(e.target.value) : '')}> - {formAssets.map(a => )} + {formAssetGroups.map(([locName, as]) => ( + + {as.map(a => )} + + ))} + {!form.location_id &&
Picking an asset fills in its location automatically.
}
)} diff --git a/frontend/src/pages/Summary.tsx b/frontend/src/pages/Summary.tsx index e1d8dec..0e56c62 100644 --- a/frontend/src/pages/Summary.tsx +++ b/frontend/src/pages/Summary.tsx @@ -1,4 +1,5 @@ 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 type { Task, Category, Location, AppConfig, TaskStatus, Priority } 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]) } + const [searchParams, setSearchParams] = useSearchParams() const [showNew, setShowNew] = useState(false) + const [newTaskPreset, setNewTaskPreset] = useState<{ locationId?: number; assetId?: number }>({}) const [openTask, setOpenTask] = useState(null) const roomsCat = useMemo(() => categories.find(c => c.is_rooms) ?? null, [categories]) @@ -80,6 +83,26 @@ export default function Summary() { 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 c: Partial> = {} for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1 @@ -141,7 +164,7 @@ export default function Summary() { Refresh {can(user, 'report') && ( - )} @@ -287,6 +310,8 @@ export default function Summary() { categories={categories} locations={locations} config={config} + initialLocationId={newTaskPreset.locationId} + initialAssetId={newTaskPreset.assetId} onClose={() => setShowNew(false)} onCreated={load} />