- #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 {
|
export interface SelectOption {
|
||||||
value: number
|
value: number
|
||||||
|
|
@ -29,12 +29,14 @@ export default function SearchSelect({ options, value, onChange, placeholder = '
|
||||||
return () => document.removeEventListener('mousedown', onDown)
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filtered = query.trim()
|
const filtered = useMemo(() => {
|
||||||
? options.filter(o =>
|
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
o.label.toLowerCase().includes(query.toLowerCase()) ||
|
if (!words.length) return options
|
||||||
(o.group?.toLowerCase().includes(query.toLowerCase()) ?? false)
|
return options.filter(o => {
|
||||||
)
|
const haystack = `${o.label} ${o.group ?? ''}`.toLowerCase()
|
||||||
: options
|
return words.every(w => haystack.includes(w))
|
||||||
|
})
|
||||||
|
}, [query, options])
|
||||||
|
|
||||||
// Group filtered results
|
// Group filtered results
|
||||||
const groups: { group: string; items: SelectOption[] }[] = []
|
const groups: { group: string; items: SelectOption[] }[] = []
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
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 type { Asset, AssetDetail, Location } from '../types'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations, createTemplate } from '../api'
|
import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations, createTemplate } from '../api'
|
||||||
|
|
@ -118,7 +118,7 @@ export default function Assets() {
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table className="data">
|
<table className="data">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{assets.map(a => (
|
{assets.map(a => (
|
||||||
|
|
@ -128,9 +128,30 @@ export default function Assets() {
|
||||||
<td>{a.make_model || '—'}</td>
|
<td>{a.make_model || '—'}</td>
|
||||||
<td>{a.open_tasks || 0}</td>
|
<td>{a.open_tasks || 0}</td>
|
||||||
<td>{a.recurring_count || 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>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react'
|
import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react'
|
||||||
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
|
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
|
||||||
import { STATUS_LABELS, can } 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 { useAuth } from '../components/AuthGate'
|
||||||
import NewTaskModal from '../components/NewTaskModal'
|
import NewTaskModal from '../components/NewTaskModal'
|
||||||
import TaskModal from '../components/TaskModal'
|
import TaskModal from '../components/TaskModal'
|
||||||
|
|
@ -18,27 +18,38 @@ export default function Summary() {
|
||||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
|
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
|
||||||
const [mineOnly, setMineOnly] = useState(false)
|
const [mineOnly, setMineOnly] = useState(false)
|
||||||
const [unoccupiedOnly, setUnoccupiedOnly] = useState(false)
|
const [roomsView, setRoomsView] = useState(false)
|
||||||
|
|
||||||
const [showNew, setShowNew] = useState(false)
|
const [showNew, setShowNew] = useState(false)
|
||||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const roomsCat = useMemo(() => categories.find(c => c.is_rooms) ?? null, [categories])
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
fetchTasks({
|
const taskFetch = fetchTasks({
|
||||||
status: statusFilter ?? OPEN_STATUSES.join(','),
|
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,
|
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))
|
.catch(err => setError(err.message))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [statusFilter, categoryFilter, mineOnly, unoccupiedOnly, user.email])
|
}, [statusFilter, categoryFilter, mineOnly, roomsView, roomsCat, user.email])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -58,7 +69,18 @@ export default function Summary() {
|
||||||
return c
|
return c
|
||||||
}, [tasks])
|
}, [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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
|
@ -88,7 +110,8 @@ export default function Summary() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chip-bar">
|
<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)}>
|
<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]})` : ''}
|
{c.name}{categoryCounts[c.id] ? ` (${categoryCounts[c.id]})` : ''}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -98,11 +121,14 @@ export default function Summary() {
|
||||||
</button>
|
</button>
|
||||||
{roomsCategoryExists && (
|
{roomsCategoryExists && (
|
||||||
<button
|
<button
|
||||||
className={`chip ${unoccupiedOnly ? 'active' : ''}`}
|
className={`chip ${roomsView ? 'active' : ''}`}
|
||||||
title="Only rooms with no in-house guest right now (live from NewBook)"
|
title="Show all rooms tasks — grey indicates an in-house guest right now"
|
||||||
onClick={() => setUnoccupiedOnly(!unoccupiedOnly)}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,41 +137,46 @@ export default function Summary() {
|
||||||
<div className="empty-state">No open tasks match these filters.</div>
|
<div className="empty-state">No open tasks match these filters.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tasks.map(t => (
|
{tasks.map(t => {
|
||||||
<div
|
const isOccupied = roomsView && !!t.newbook_site_id && occupiedSiteIds.has(String(t.newbook_site_id))
|
||||||
key={t.id}
|
return (
|
||||||
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
|
<div
|
||||||
onClick={() => setOpenTask(t.id)}
|
key={t.id}
|
||||||
>
|
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
|
||||||
<div className="task-card-main">
|
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined}
|
||||||
<div className="task-card-title">
|
onClick={() => setOpenTask(t.id)}
|
||||||
{t.title}
|
>
|
||||||
{t.unusable && <UnusableBadge />}
|
<div className="task-card-main">
|
||||||
{t.template_id && <span className="badge badge-outline">Recurring</span>}
|
<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>
|
||||||
|
<div className="task-card-meta">
|
||||||
|
<span>{t.location_name}</span>
|
||||||
|
<span>{t.category_name}</span>
|
||||||
|
{t.asset_name && <span>{t.asset_name}</span>}
|
||||||
|
<span>{ageLabel(t.created_at)} old</span>
|
||||||
|
{t.due_date && (
|
||||||
|
<span className={new Date(t.due_date) < new Date() ? 'overdue' : ''}>
|
||||||
|
due {formatDate(t.due_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.hold_until && <span>held until {formatDate(t.hold_until)}</span>}
|
||||||
|
{t.photo_count > 0 && <span><Camera size={12} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> {t.photo_count}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-card-meta">
|
<div className="task-card-side">
|
||||||
<span>{t.location_name}</span>
|
<PriorityBadge priority={t.priority} />
|
||||||
<span>{t.category_name}</span>
|
<StatusBadge status={t.status} />
|
||||||
{t.asset_name && <span>{t.asset_name}</span>}
|
<span className="muted" style={{ fontSize: 11.5 }}>
|
||||||
<span>{ageLabel(t.created_at)} old</span>
|
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
|
||||||
{t.due_date && (
|
</span>
|
||||||
<span className={new Date(t.due_date) < new Date() ? 'overdue' : ''}>
|
|
||||||
due {formatDate(t.due_date)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{t.hold_until && <span>held until {formatDate(t.hold_until)}</span>}
|
|
||||||
{t.photo_count > 0 && <span><Camera size={12} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> {t.photo_count}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-card-side">
|
)
|
||||||
<PriorityBadge priority={t.priority} />
|
})}
|
||||||
<StatusBadge status={t.status} />
|
|
||||||
<span className="muted" style={{ fontSize: 11.5 }}>
|
|
||||||
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{showNew && (
|
{showNew && (
|
||||||
<NewTaskModal
|
<NewTaskModal
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue