- #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:
parent
859874f614
commit
2a2b14029a
3 changed files with 109 additions and 55 deletions
|
|
@ -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[] }[] = []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue