From 2a2b14029a1f19d6cc8f327ab4e67464e32baae4 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 8 Jul 2026 12:26:43 +0000 Subject: [PATCH] Fix issues #2 #5 #6 #8: fuzzy search, rooms-view, asset edit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #2 #5: SearchSelect now splits query into words — each word must appear anywhere in label or group, so '101' matches 'Room 101' and 'room 101' matches too (multi-word, order-independent) - #6: Rooms-view chip replaces 'Unoccupied rooms only'. When active: filters task list to rooms category only, fetches live occupancy, and shows occupied-room tasks greyed out with an 'Occupied' badge — so small jobs (bulb changes etc.) are still visible without being buried. Category chips hide while rooms-view is on to avoid conflict. - #8: Rooms-view chip shows total rooms count + free count e.g. 'Rooms (5 • 3 free)'. Category chip counts stay as-is (full count) and hide when rooms-view is active. - #3/#4 follow-up: Add pencil edit button on every asset table row so users can edit an asset directly without opening the detail modal first. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/SearchSelect.tsx | 16 +-- frontend/src/pages/Assets.tsx | 27 ++++- frontend/src/pages/Summary.tsx | 121 ++++++++++++++--------- 3 files changed, 109 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/SearchSelect.tsx b/frontend/src/components/SearchSelect.tsx index 9633c0c..1dc21e3 100644 --- a/frontend/src/components/SearchSelect.tsx +++ b/frontend/src/components/SearchSelect.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' export interface SelectOption { value: number @@ -29,12 +29,14 @@ export default function SearchSelect({ options, value, onChange, placeholder = ' 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 + const filtered = useMemo(() => { + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean) + if (!words.length) return options + return options.filter(o => { + const haystack = `${o.label} ${o.group ?? ''}`.toLowerCase() + return words.every(w => haystack.includes(w)) + }) + }, [query, options]) // Group filtered results const groups: { group: string; items: SelectOption[] }[] = [] diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx index 8da7cef..1438fe1 100644 --- a/frontend/src/pages/Assets.tsx +++ b/frontend/src/pages/Assets.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Plus, X } from 'lucide-react' +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' @@ -118,7 +118,7 @@ export default function Assets() {
- + {canManage && } {assets.map(a => ( @@ -128,9 +128,30 @@ export default function Assets() { + {canManage && ( + + )} ))} - {assets.length === 0 && } + {assets.length === 0 && }
AssetLocationMake / modelOpen tasksRecurring
AssetLocationMake / modelOpen tasksRecurring
{a.make_model || '—'} {a.open_tasks || 0} {a.recurring_count || 0} + +
No assets yet — add the boiler, lifts, fridges…
No assets yet — add the boiler, lifts, fridges…
diff --git a/frontend/src/pages/Summary.tsx b/frontend/src/pages/Summary.tsx index 1edacfd..37ca770 100644 --- a/frontend/src/pages/Summary.tsx +++ b/frontend/src/pages/Summary.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react' import type { Task, Category, Location, AppConfig, TaskStatus } from '../types' import { STATUS_LABELS, can } from '../types' -import { fetchTasks, fetchLocations, fetchConfig } from '../api' +import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api' import { useAuth } from '../components/AuthGate' import NewTaskModal from '../components/NewTaskModal' import TaskModal from '../components/TaskModal' @@ -18,27 +18,38 @@ export default function Summary() { const [config, setConfig] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const [occupiedSiteIds, setOccupiedSiteIds] = useState>(new Set()) const [statusFilter, setStatusFilter] = useState(null) const [categoryFilter, setCategoryFilter] = useState(null) const [mineOnly, setMineOnly] = useState(false) - const [unoccupiedOnly, setUnoccupiedOnly] = useState(false) + const [roomsView, setRoomsView] = useState(false) const [showNew, setShowNew] = useState(false) const [openTask, setOpenTask] = useState(null) + const roomsCat = useMemo(() => categories.find(c => c.is_rooms) ?? null, [categories]) + const load = useCallback(() => { setLoading(true) - fetchTasks({ + const taskFetch = fetchTasks({ status: statusFilter ?? OPEN_STATUSES.join(','), - category_id: categoryFilter ?? undefined, + // In rooms-view mode filter to rooms category; otherwise use the active category chip + category_id: roomsView ? (roomsCat?.id ?? undefined) : (categoryFilter ?? undefined), assigned_to: mineOnly ? user.email : undefined, - unoccupied: unoccupiedOnly, }) - .then(t => { setTasks(t); setError(null) }) + // When rooms-view is on, also fetch live occupancy to mark occupied tasks + const occFetch = roomsView ? fetchOccupancy().catch(() => null) : Promise.resolve(null) + + Promise.all([taskFetch, occFetch]) + .then(([t, occ]) => { + setTasks(t) + setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set()) + setError(null) + }) .catch(err => setError(err.message)) .finally(() => setLoading(false)) - }, [statusFilter, categoryFilter, mineOnly, unoccupiedOnly, user.email]) + }, [statusFilter, categoryFilter, mineOnly, roomsView, roomsCat, user.email]) useEffect(() => { load() }, [load]) useEffect(() => { @@ -58,7 +69,18 @@ export default function Summary() { return c }, [tasks]) - const roomsCategoryExists = categories.some(c => c.is_rooms) + // Count of rooms tasks with no in-house guest right now + const freeRoomsCount = useMemo(() => { + if (!roomsView) return null + return tasks.filter(t => !t.newbook_site_id || !occupiedSiteIds.has(String(t.newbook_site_id))).length + }, [tasks, roomsView, occupiedSiteIds]) + + const roomsCategoryExists = !!roomsCat + + function toggleRoomsView() { + setRoomsView(v => !v) + setCategoryFilter(null) // clear any category chip when entering/leaving rooms-view + } return (
@@ -88,7 +110,8 @@ export default function Summary() {
- {categories.map(c => ( + {/* Category chips hidden while in rooms-view (rooms filter is the active "category") */} + {!roomsView && categories.map(c => ( @@ -98,11 +121,14 @@ export default function Summary() { {roomsCategoryExists && ( )}
@@ -111,41 +137,46 @@ export default function Summary() {
No open tasks match these filters.
)} - {tasks.map(t => ( -
setOpenTask(t.id)} - > -
-
- {t.title} - {t.unusable && } - {t.template_id && Recurring} + {tasks.map(t => { + const isOccupied = roomsView && !!t.newbook_site_id && occupiedSiteIds.has(String(t.newbook_site_id)) + return ( +
setOpenTask(t.id)} + > +
+
+ {t.title} + {isOccupied && Occupied} + {t.unusable && } + {t.template_id && Recurring} +
+
+ {t.location_name} + {t.category_name} + {t.asset_name && {t.asset_name}} + {ageLabel(t.created_at)} old + {t.due_date && ( + + due {formatDate(t.due_date)} + + )} + {t.hold_until && held until {formatDate(t.hold_until)}} + {t.photo_count > 0 && {t.photo_count}} +
-
- {t.location_name} - {t.category_name} - {t.asset_name && {t.asset_name}} - {ageLabel(t.created_at)} old - {t.due_date && ( - - due {formatDate(t.due_date)} - - )} - {t.hold_until && held until {formatDate(t.hold_until)}} - {t.photo_count > 0 && {t.photo_count}} +
+ + + + {t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')} +
-
- - - - {t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')} - -
-
- ))} + ) + })} {showNew && (