From 859874f614a033a02337a7e5c1dd47d7d420b20d Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 8 Jul 2026 12:09:57 +0000 Subject: [PATCH] Fix issues #2-#6 and #8: searchable pickers, occupancy filter, asset UX, counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #2 #5: Replace plain { setLocationId(e.target.value ? parseInt(e.target.value) : ''); setAssetId('') }}> - - {grouped.map(g => ( - - {g.locations.map(l => )} - - ))} - + { setLocationId(v); setAssetId('') }} + placeholder="Search locations…" + /> {existing.length > 0 && ( diff --git a/frontend/src/components/SearchSelect.tsx b/frontend/src/components/SearchSelect.tsx new file mode 100644 index 0000000..9633c0c --- /dev/null +++ b/frontend/src/components/SearchSelect.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from 'react' + +export interface SelectOption { + value: number + label: string + group?: string +} + +interface Props { + options: SelectOption[] + value: number | '' + onChange: (value: number | '') => void + placeholder?: string +} + +export default function SearchSelect({ options, value, onChange, placeholder = 'Search…' }: Props) { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + + const selected = options.find(o => o.value === value) + + // Close on outside click + useEffect(() => { + function onDown(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, []) + + const filtered = query.trim() + ? options.filter(o => + o.label.toLowerCase().includes(query.toLowerCase()) || + (o.group?.toLowerCase().includes(query.toLowerCase()) ?? false) + ) + : options + + // Group filtered results + const groups: { group: string; items: SelectOption[] }[] = [] + for (const opt of filtered) { + const g = opt.group ?? '' + const existing = groups.find(x => x.group === g) + if (existing) existing.items.push(opt) + else groups.push({ group: g, items: [opt] }) + } + + function select(opt: SelectOption) { + onChange(opt.value) + setQuery('') + setOpen(false) + } + + function clear() { + onChange('') + setQuery('') + } + + return ( +
+ { setQuery(e.target.value); setOpen(true) }} + onFocus={() => { setQuery(''); setOpen(true) }} + onKeyDown={e => { + if (e.key === 'Escape') setOpen(false) + if (e.key === 'Backspace' && !query && value !== '') clear() + }} + autoComplete="off" + /> + {open && filtered.length > 0 && ( +
+ {groups.map(g => ( +
+ {g.group && ( +
+ {g.group} +
+ )} + {g.items.map(opt => ( +
{ e.preventDefault(); select(opt) }} + style={{ + padding: '7px 12px', cursor: 'pointer', fontSize: 13.5, + background: opt.value === value ? 'var(--warn-bg)' : undefined, + color: 'var(--text-dark)', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f1f5f9')} + onMouseLeave={e => (e.currentTarget.style.background = opt.value === value ? 'var(--warn-bg)' : '')} + > + {opt.label} +
+ ))} +
+ ))} +
+ )} + {open && filtered.length === 0 && ( +
+ No matches +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx index 085387b..8da7cef 100644 --- a/frontend/src/pages/Assets.tsx +++ b/frontend/src/pages/Assets.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Plus, X } from 'lucide-react' import type { Asset, AssetDetail, Location } from '../types' import { can } from '../types' -import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations } from '../api' +import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations, createTemplate } from '../api' import { useAuth } from '../components/AuthGate' +import SearchSelect from '../components/SearchSelect' import TaskModal from '../components/TaskModal' import { PriorityBadge, StatusBadge, formatDate } from '../components/shared' @@ -14,11 +15,22 @@ interface AssetForm { make_model: string serial_no: string install_date: string + install_date_unknown: boolean notes: string active: boolean } -const EMPTY: AssetForm = { name: '', location_id: '', make_model: '', serial_no: '', install_date: '', notes: '', active: true } +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, +} export default function Assets() { const { user } = useAuth() @@ -26,10 +38,16 @@ export default function Assets() { 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 locationOptions = useMemo(() => + locations.map(l => ({ value: l.id, label: l.name, group: l.category_name })), + [locations]) const load = useCallback(() => { fetchAssets().then(setAssets).catch(err => setError(err.message)) @@ -46,7 +64,7 @@ export default function Assets() { location_id: form.location_id, make_model: form.make_model || null, serial_no: form.serial_no || null, - install_date: form.install_date || null, + install_date: form.install_date_unknown ? null : (form.install_date || null), notes: form.notes || null, active: form.active, } @@ -60,6 +78,30 @@ 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 (
@@ -109,18 +151,60 @@ export default function Assets() {
{detail.notes &&

{detail.notes}

} - {detail.templates.length > 0 && ( - <> -
Recurring service tasks
- {detail.templates.map(tp => ( -
- {tp.title} - every {tp.interval_value} {tp.interval_unit} - next due {formatDate(tp.next_due)} - {!tp.active && paused} +
+ Recurring service tasks + {canManageTemplates && !serviceForm && ( + + )} +
+ {detail.templates.map(tp => ( +
+ {tp.title} + every {tp.interval_value} {tp.interval_unit} + next due {formatDate(tp.next_due)} + {!tp.active && paused} +
+ ))} + {detail.templates.length === 0 && !serviceForm && ( +
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
@@ -144,7 +228,9 @@ export default function Assets() { setForm({ id: detail.id, name: detail.name, location_id: detail.location_id, make_model: detail.make_model || '', serial_no: detail.serial_no || '', - install_date: detail.install_date?.slice(0, 10) || '', notes: detail.notes || '', + install_date: detail.install_date?.slice(0, 10) || '', + install_date_unknown: !detail.install_date, + notes: detail.notes || '', active: detail.active, }) setDetail(null) @@ -167,10 +253,12 @@ export default function Assets() { setForm({ ...form, name: e.target.value })} placeholder="e.g. Kitchen walk-in fridge" autoFocus />
- + setForm({ ...form, location_id: v })} + placeholder="Search locations…" + />
@@ -180,8 +268,19 @@ export default function Assets() { setForm({ ...form, serial_no: e.target.value })} />
-
- setForm({ ...form, install_date: e.target.value })} /> +
+ +
+ setForm({ ...form, install_date: e.target.value })} + style={{ flex: 1, opacity: form.install_date_unknown ? 0.4 : 1 }} /> + +