Fix issues #2 #5 #6 #8: fuzzy search, rooms-view, asset edit button

- #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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-08 12:26:43 +00:00
parent 859874f614
commit 2a2b14029a
3 changed files with 109 additions and 55 deletions

View file

@ -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[] }[] = []

View file

@ -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() {
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Asset</th><th>Location</th><th>Make / model</th><th>Open tasks</th><th>Recurring</th></tr>
<tr><th>Asset</th><th>Location</th><th>Make / model</th><th>Open tasks</th><th>Recurring</th>{canManage && <th></th>}</tr>
</thead>
<tbody>
{assets.map(a => (
@ -128,9 +128,30 @@ export default function Assets() {
<td>{a.make_model || '—'}</td>
<td>{a.open_tasks || 0}</td>
<td>{a.recurring_count || 0}</td>
{canManage && (
<td style={{ width: 32, padding: '0 4px' }}>
<button
className="btn btn-sm"
style={{ padding: '3px 6px' }}
title="Edit asset"
onClick={e => {
e.stopPropagation()
setForm({
id: a.id, name: a.name, location_id: a.location_id,
make_model: a.make_model || '', serial_no: a.serial_no || '',
install_date: a.install_date?.slice(0, 10) || '',
install_date_unknown: !a.install_date,
notes: a.notes || '', active: a.active,
})
}}
>
<Pencil size={12} strokeWidth={1.75} />
</button>
</td>
)}
</tr>
))}
{assets.length === 0 && <tr><td colSpan={5} className="empty-state">No assets yet add the boiler, lifts, fridges</td></tr>}
{assets.length === 0 && <tr><td colSpan={canManage ? 6 : 5} className="empty-state">No assets yet add the boiler, lifts, fridges</td></tr>}
</tbody>
</table>
</div>

View file

@ -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<AppConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set())
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number | null>(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<number | null>(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 (
<div className="page">
@ -88,7 +110,8 @@ export default function Summary() {
</div>
<div className="chip-bar">
{categories.map(c => (
{/* Category chips hidden while in rooms-view (rooms filter is the active "category") */}
{!roomsView && categories.map(c => (
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
{c.name}{categoryCounts[c.id] ? ` (${categoryCounts[c.id]})` : ''}
</button>
@ -98,11 +121,14 @@ export default function Summary() {
</button>
{roomsCategoryExists && (
<button
className={`chip ${unoccupiedOnly ? 'active' : ''}`}
title="Only rooms with no in-house guest right now (live from NewBook)"
onClick={() => setUnoccupiedOnly(!unoccupiedOnly)}
className={`chip ${roomsView ? 'active' : ''}`}
title="Show all rooms tasks — grey indicates an in-house guest right now"
onClick={toggleRoomsView}
>
<BedDouble size={13} strokeWidth={1.75} /> Unoccupied rooms only
<BedDouble size={13} strokeWidth={1.75} />
{roomsView
? `Rooms (${tasks.length}${freeRoomsCount !== null ? `${freeRoomsCount} free` : ''})`
: 'Rooms view'}
</button>
)}
</div>
@ -111,15 +137,19 @@ export default function Summary() {
<div className="empty-state">No open tasks match these filters.</div>
)}
{tasks.map(t => (
{tasks.map(t => {
const isOccupied = roomsView && !!t.newbook_site_id && occupiedSiteIds.has(String(t.newbook_site_id))
return (
<div
key={t.id}
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined}
onClick={() => setOpenTask(t.id)}
>
<div className="task-card-main">
<div className="task-card-title">
{t.title}
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>}
{t.unusable && <UnusableBadge />}
{t.template_id && <span className="badge badge-outline">Recurring</span>}
</div>
@ -145,7 +175,8 @@ export default function Summary() {
</span>
</div>
</div>
))}
)
})}
{showNew && (
<NewTaskModal