diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js
index cacd6be..cfea7c5 100644
--- a/backend/src/lib/newbook.js
+++ b/backend/src/lib/newbook.js
@@ -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 ?? []
}
diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js
index 8bf7b7f..b36f09f 100644
--- a/backend/src/routes/tasks.js
+++ b/backend/src/routes/tasks.js
@@ -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
}
diff --git a/frontend/src/components/NewTaskModal.tsx b/frontend/src/components/NewTaskModal.tsx
index 95c05f0..86c1afc 100644
--- a/frontend/src/components/NewTaskModal.tsx
+++ b/frontend/src/components/NewTaskModal.tsx
@@ -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
-
+ { setLocationId(v); setAssetId('') }}
+ placeholder="Search locations…"
+ />
{existing.length > 0 && (
diff --git a/frontend/src/components/SearchSelect.tsx b/frontend/src/components/SearchSelect.tsx
new file mode 100644
index 0000000..9633c0c
--- /dev/null
+++ b/frontend/src/components/SearchSelect.tsx
@@ -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(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 (
+
+
{ 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 && (
+
+ {groups.map(g => (
+
+ {g.group && (
+
+ {g.group}
+
+ )}
+ {g.items.map(opt => (
+
{ 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}
+
+ ))}
+
+ ))}
+
+ )}
+ {open && filtered.length === 0 && (
+
+ No matches
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx
index 085387b..8da7cef 100644
--- a/frontend/src/pages/Assets.tsx
+++ b/frontend/src/pages/Assets.tsx
@@ -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([])
const [detail, setDetail] = useState(null)
const [form, setForm] = useState(null)
+ const [serviceForm, setServiceForm] = useState(null)
const [error, setError] = useState(null)
const [openTask, setOpenTask] = useState(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 (
@@ -109,18 +151,60 @@ export default function Assets() {
{detail.notes &&
{detail.notes}
}
- {detail.templates.length > 0 && (
- <>
-
Recurring service tasks
- {detail.templates.map(tp => (
-
-
{tp.title}
-
every {tp.interval_value} {tp.interval_unit}
-
next due {formatDate(tp.next_due)}
- {!tp.active &&
paused}
+
+ Recurring service tasks
+ {canManageTemplates && !serviceForm && (
+
+ )}
+
+ {detail.templates.map(tp => (
+
+ {tp.title}
+ every {tp.interval_value} {tp.interval_unit}
+ next due {formatDate(tp.next_due)}
+ {!tp.active && paused}
+
+ ))}
+ {detail.templates.length === 0 && !serviceForm && (
+
No recurring service tasks for this asset.
+ )}
+ {serviceForm && (
+
+
+
+ setServiceForm({ ...serviceForm, title: e.target.value })}
+ placeholder="e.g. Annual boiler service" />
+
+
+
+
+ setServiceForm({ ...serviceForm, interval_value: e.target.value })}
+ style={{ width: 64 }} />
- ))}
- >
+
+
+
+
+
+
+ setServiceForm({ ...serviceForm, next_due: e.target.value })} />
+
+
+
+
+
+
+
)}
Task history
@@ -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() {
setForm({ ...form, name: e.target.value })} placeholder="e.g. Kitchen walk-in fridge" autoFocus />
-
+ setForm({ ...form, location_id: v })}
+ placeholder="Search locations…"
+ />
-