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:
parent
2a3a226131
commit
859874f614
6 changed files with 268 additions and 39 deletions
|
|
@ -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 })} />
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue