Fix issues #2-#6 and #8: searchable pickers, occupancy filter, asset UX, counts

- #2 #5: Replace plain <select> location pickers in NewTask and Assets with
  SearchSelect combobox — full-text search across name and category group
- #3: Add "Unknown" checkbox on asset install date — disables the field and
  saves null instead of leaving an ambiguous blank
- #4: Add inline "Add service task" form in asset detail modal — creates a
  recurring template pre-linked to the asset without leaving the page
- #6: Fix unoccupied rooms filter — was always empty because (a) list_type
  was 'all' instead of 'staying' and (b) only checked b.site_id but NewBook
  returns booking_site_id on most account configs
- #8: Add open-task count badges to category filter chips on Summary page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-08 12:09:57 +00:00
parent 2a3a226131
commit 859874f614
6 changed files with 268 additions and 39 deletions

View file

@ -60,7 +60,7 @@ export async function fetchBookings(fromDate, toDate) {
const res = await callApi('bookings_list', {
period_from: `${fromDate} 00:00:00`,
period_to: `${toDate} 23:59:59`,
list_type: 'all',
list_type: 'staying',
})
return res?.data ?? []
}

View file

@ -30,9 +30,9 @@ async function fetchOccupiedSiteIds() {
const bookings = await fetchBookings(today, today)
const occupied = new Set()
for (const b of bookings) {
if (String(b.booking_status).toLowerCase() === 'arrived' && b.site_id != null) {
occupied.add(String(b.site_id))
}
// NewBook returns booking_site_id on some account configurations, site_id on others
const siteId = b.booking_site_id ?? b.site_id
if (siteId != null) occupied.add(String(siteId))
}
return occupied
}

View file

@ -4,6 +4,7 @@ import type { Category, Location, Task, Priority, AppConfig, Asset } from '../ty
import { PRIORITIES, PRIORITY_LABELS } from '../types'
import { createTask, fetchTasks, uploadTaskPhoto, fetchAssets } from '../api'
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
import SearchSelect from './SearchSelect'
import { PriorityBadge, StatusBadge } from './shared'
export default function NewTaskModal({ categories, locations, config, onClose, onCreated }: {
@ -46,10 +47,13 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
fetchTasks({ location_id: locationId as number }).then(setExisting).catch(() => setExisting([]))
}, [locationId])
const grouped = useMemo(() => categories.map(c => ({
category: c,
locations: locations.filter(l => l.category_id === c.id && l.active),
})).filter(g => g.locations.length), [categories, locations])
const locationOptions = useMemo(() =>
categories.flatMap(c =>
locations
.filter(l => l.category_id === c.id && l.active)
.map(l => ({ value: l.id, label: l.name, group: c.name }))
),
[categories, locations])
async function submit() {
if (!title.trim() || !locationId) { setError('Title and location are required'); return }
@ -100,14 +104,12 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
<div className="field">
<label>Location</label>
<select value={locationId} onChange={e => { setLocationId(e.target.value ? parseInt(e.target.value) : ''); setAssetId('') }}>
<option value="">Select location</option>
{grouped.map(g => (
<optgroup key={g.category.id} label={g.category.name}>
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</optgroup>
))}
</select>
<SearchSelect
options={locationOptions}
value={locationId}
onChange={v => { setLocationId(v); setAssetId('') }}
placeholder="Search locations…"
/>
</div>
{existing.length > 0 && (

View file

@ -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<HTMLDivElement>(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 (
<div ref={wrapRef} style={{ position: 'relative' }}>
<input
type="text"
value={open ? query : (selected?.label ?? '')}
placeholder={placeholder}
onChange={e => { 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 && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100,
background: '#fff', border: '1px solid var(--card-border)',
borderRadius: 6, boxShadow: 'var(--shadow-md)',
maxHeight: 240, overflowY: 'auto', marginTop: 2,
}}>
{groups.map(g => (
<div key={g.group}>
{g.group && (
<div style={{
padding: '5px 10px 2px', fontSize: 11, fontWeight: 600,
color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.04em',
borderTop: '1px solid var(--card-border)', marginTop: 2,
}}>
{g.group}
</div>
)}
{g.items.map(opt => (
<div
key={opt.value}
onMouseDown={e => { 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}
</div>
))}
</div>
))}
</div>
)}
{open && filtered.length === 0 && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100,
background: '#fff', border: '1px solid var(--card-border)',
borderRadius: 6, boxShadow: 'var(--shadow-md)',
padding: '8px 12px', fontSize: 13, color: 'var(--text-mid)', marginTop: 2,
}}>
No matches
</div>
)}
</div>
)
}

View file

@ -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<Location[]>([])
const [detail, setDetail] = useState<AssetDetail | null>(null)
const [form, setForm] = useState<AssetForm | null>(null)
const [serviceForm, setServiceForm] = useState<ServiceTaskForm | null>(null)
const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(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 (
<div className="page">
<div className="page-header">
@ -109,18 +151,60 @@ export default function Assets() {
</div>
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
{detail.templates.length > 0 && (
<>
<div className="section-title">Recurring service tasks</div>
{detail.templates.map(tp => (
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}>
<span>{tp.title}</span>
<span>every {tp.interval_value} {tp.interval_unit}</span>
<span>next due {formatDate(tp.next_due)}</span>
{!tp.active && <span className="badge badge-outline">paused</span>}
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Recurring service tasks
{canManageTemplates && !serviceForm && (
<button className="btn btn-sm" onClick={() => setServiceForm({ title: '', interval_value: '1', interval_unit: 'months', next_due: '' })}>
<Plus size={12} strokeWidth={1.75} /> Add
</button>
)}
</div>
{detail.templates.map(tp => (
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}>
<span>{tp.title}</span>
<span>every {tp.interval_value} {tp.interval_unit}</span>
<span>next due {formatDate(tp.next_due)}</span>
{!tp.active && <span className="badge badge-outline">paused</span>}
</div>
))}
{detail.templates.length === 0 && !serviceForm && (
<div className="muted" style={{ fontSize: 12.5 }}>No recurring service tasks for this asset.</div>
)}
{serviceForm && (
<div className="card" style={{ marginTop: 8, padding: 12 }}>
<div className="field" style={{ marginBottom: 8 }}>
<label>Task title</label>
<input type="text" value={serviceForm.title} autoFocus
onChange={e => setServiceForm({ ...serviceForm, title: e.target.value })}
placeholder="e.g. Annual boiler service" />
</div>
<div className="field-row" style={{ marginBottom: 8 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>Every</label>
<input type="number" min={1} value={serviceForm.interval_value}
onChange={e => setServiceForm({ ...serviceForm, interval_value: e.target.value })}
style={{ width: 64 }} />
</div>
))}
</>
<div className="field" style={{ marginBottom: 0 }}>
<label>Unit</label>
<select value={serviceForm.interval_unit}
onChange={e => setServiceForm({ ...serviceForm, interval_unit: e.target.value as ServiceTaskForm['interval_unit'] })}>
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Next due</label>
<input type="date" value={serviceForm.next_due}
onChange={e => setServiceForm({ ...serviceForm, next_due: e.target.value })} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm" onClick={() => setServiceForm(null)}>Cancel</button>
<button className="btn btn-sm btn-primary" onClick={() => saveServiceTask(detail.id, detail.location_id)}>Save</button>
</div>
</div>
)}
<div className="section-title">Task history</div>
@ -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() {
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="e.g. Kitchen walk-in fridge" autoFocus />
</div>
<div className="field"><label>Location</label>
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '' })}>
<option value="">Select location</option>
{locations.map(l => <option key={l.id} value={l.id}>{l.name} ({l.category_name})</option>)}
</select>
<SearchSelect
options={locationOptions}
value={form.location_id}
onChange={v => setForm({ ...form, location_id: v })}
placeholder="Search locations…"
/>
</div>
<div className="field-row">
<div className="field"><label>Make / model</label>
@ -180,8 +268,19 @@ export default function Assets() {
<input type="text" value={form.serial_no} onChange={e => setForm({ ...form, serial_no: e.target.value })} />
</div>
</div>
<div className="field"><label>Install date</label>
<input type="date" value={form.install_date} onChange={e => setForm({ ...form, install_date: e.target.value })} />
<div className="field">
<label>Install date</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input type="date" value={form.install_date_unknown ? '' : form.install_date}
disabled={form.install_date_unknown}
onChange={e => setForm({ ...form, install_date: e.target.value })}
style={{ flex: 1, opacity: form.install_date_unknown ? 0.4 : 1 }} />
<label style={{ display: 'flex', gap: 5, alignItems: 'center', fontSize: 12.5, color: 'var(--text-mid)', whiteSpace: 'nowrap', cursor: 'pointer' }}>
<input type="checkbox" checked={form.install_date_unknown}
onChange={e => setForm({ ...form, install_date_unknown: e.target.checked, install_date: '' })} />
Unknown
</label>
</div>
</div>
<div className="field"><label>Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />

View file

@ -52,6 +52,12 @@ export default function Summary() {
return c
}, [tasks])
const categoryCounts = useMemo(() => {
const c: Record<number, number> = {}
for (const t of tasks) c[t.category_id] = (c[t.category_id] || 0) + 1
return c
}, [tasks])
const roomsCategoryExists = categories.some(c => c.is_rooms)
return (
@ -84,7 +90,7 @@ export default function Summary() {
<div className="chip-bar">
{categories.map(c => (
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
{c.name}
{c.name}{categoryCounts[c.id] ? ` (${categoryCounts[c.id]})` : ''}
</button>
))}
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>