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
|
|
@ -60,7 +60,7 @@ export async function fetchBookings(fromDate, toDate) {
|
||||||
const res = await callApi('bookings_list', {
|
const res = await callApi('bookings_list', {
|
||||||
period_from: `${fromDate} 00:00:00`,
|
period_from: `${fromDate} 00:00:00`,
|
||||||
period_to: `${toDate} 23:59:59`,
|
period_to: `${toDate} 23:59:59`,
|
||||||
list_type: 'all',
|
list_type: 'staying',
|
||||||
})
|
})
|
||||||
return res?.data ?? []
|
return res?.data ?? []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@ async function fetchOccupiedSiteIds() {
|
||||||
const bookings = await fetchBookings(today, today)
|
const bookings = await fetchBookings(today, today)
|
||||||
const occupied = new Set()
|
const occupied = new Set()
|
||||||
for (const b of bookings) {
|
for (const b of bookings) {
|
||||||
if (String(b.booking_status).toLowerCase() === 'arrived' && b.site_id != null) {
|
// NewBook returns booking_site_id on some account configurations, site_id on others
|
||||||
occupied.add(String(b.site_id))
|
const siteId = b.booking_site_id ?? b.site_id
|
||||||
}
|
if (siteId != null) occupied.add(String(siteId))
|
||||||
}
|
}
|
||||||
return occupied
|
return occupied
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { Category, Location, Task, Priority, AppConfig, Asset } from '../ty
|
||||||
import { PRIORITIES, PRIORITY_LABELS } from '../types'
|
import { PRIORITIES, PRIORITY_LABELS } from '../types'
|
||||||
import { createTask, fetchTasks, uploadTaskPhoto, fetchAssets } from '../api'
|
import { createTask, fetchTasks, uploadTaskPhoto, fetchAssets } from '../api'
|
||||||
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
|
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
|
||||||
|
import SearchSelect from './SearchSelect'
|
||||||
import { PriorityBadge, StatusBadge } from './shared'
|
import { PriorityBadge, StatusBadge } from './shared'
|
||||||
|
|
||||||
export default function NewTaskModal({ categories, locations, config, onClose, onCreated }: {
|
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([]))
|
fetchTasks({ location_id: locationId as number }).then(setExisting).catch(() => setExisting([]))
|
||||||
}, [locationId])
|
}, [locationId])
|
||||||
|
|
||||||
const grouped = useMemo(() => categories.map(c => ({
|
const locationOptions = useMemo(() =>
|
||||||
category: c,
|
categories.flatMap(c =>
|
||||||
locations: locations.filter(l => l.category_id === c.id && l.active),
|
locations
|
||||||
})).filter(g => g.locations.length), [categories, 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() {
|
async function submit() {
|
||||||
if (!title.trim() || !locationId) { setError('Title and location are required'); return }
|
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">
|
<div className="field">
|
||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
<select value={locationId} onChange={e => { setLocationId(e.target.value ? parseInt(e.target.value) : ''); setAssetId('') }}>
|
<SearchSelect
|
||||||
<option value="">Select location…</option>
|
options={locationOptions}
|
||||||
{grouped.map(g => (
|
value={locationId}
|
||||||
<optgroup key={g.category.id} label={g.category.name}>
|
onChange={v => { setLocationId(v); setAssetId('') }}
|
||||||
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
placeholder="Search locations…"
|
||||||
</optgroup>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{existing.length > 0 && (
|
{existing.length > 0 && (
|
||||||
|
|
|
||||||
122
frontend/src/components/SearchSelect.tsx
Normal file
122
frontend/src/components/SearchSelect.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Plus, X } from 'lucide-react'
|
import { 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 } from '../api'
|
import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations, createTemplate } from '../api'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
|
import SearchSelect from '../components/SearchSelect'
|
||||||
import TaskModal from '../components/TaskModal'
|
import TaskModal from '../components/TaskModal'
|
||||||
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
|
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
|
||||||
|
|
||||||
|
|
@ -14,11 +15,22 @@ interface AssetForm {
|
||||||
make_model: string
|
make_model: string
|
||||||
serial_no: string
|
serial_no: string
|
||||||
install_date: string
|
install_date: string
|
||||||
|
install_date_unknown: boolean
|
||||||
notes: string
|
notes: string
|
||||||
active: boolean
|
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() {
|
export default function Assets() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -26,10 +38,16 @@ export default function Assets() {
|
||||||
const [locations, setLocations] = useState<Location[]>([])
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
const [detail, setDetail] = useState<AssetDetail | null>(null)
|
const [detail, setDetail] = useState<AssetDetail | null>(null)
|
||||||
const [form, setForm] = useState<AssetForm | null>(null)
|
const [form, setForm] = useState<AssetForm | null>(null)
|
||||||
|
const [serviceForm, setServiceForm] = useState<ServiceTaskForm | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||||
|
|
||||||
const canManage = can(user, 'manage_assets')
|
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(() => {
|
const load = useCallback(() => {
|
||||||
fetchAssets().then(setAssets).catch(err => setError(err.message))
|
fetchAssets().then(setAssets).catch(err => setError(err.message))
|
||||||
|
|
@ -46,7 +64,7 @@ export default function Assets() {
|
||||||
location_id: form.location_id,
|
location_id: form.location_id,
|
||||||
make_model: form.make_model || null,
|
make_model: form.make_model || null,
|
||||||
serial_no: form.serial_no || 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,
|
notes: form.notes || null,
|
||||||
active: form.active,
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
|
|
@ -109,9 +151,14 @@ export default function Assets() {
|
||||||
</div>
|
</div>
|
||||||
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
|
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
|
||||||
|
|
||||||
{detail.templates.length > 0 && (
|
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<>
|
Recurring service tasks
|
||||||
<div className="section-title">Recurring service tasks</div>
|
{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 => (
|
{detail.templates.map(tp => (
|
||||||
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}>
|
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}>
|
||||||
<span>{tp.title}</span>
|
<span>{tp.title}</span>
|
||||||
|
|
@ -120,7 +167,44 @@ export default function Assets() {
|
||||||
{!tp.active && <span className="badge badge-outline">paused</span>}
|
{!tp.active && <span className="badge badge-outline">paused</span>}
|
||||||
</div>
|
</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>
|
<div className="section-title">Task history</div>
|
||||||
|
|
@ -144,7 +228,9 @@ export default function Assets() {
|
||||||
setForm({
|
setForm({
|
||||||
id: detail.id, name: detail.name, location_id: detail.location_id,
|
id: detail.id, name: detail.name, location_id: detail.location_id,
|
||||||
make_model: detail.make_model || '', serial_no: detail.serial_no || '',
|
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,
|
active: detail.active,
|
||||||
})
|
})
|
||||||
setDetail(null)
|
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 />
|
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="e.g. Kitchen walk-in fridge" autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="field"><label>Location</label>
|
<div className="field"><label>Location</label>
|
||||||
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '' })}>
|
<SearchSelect
|
||||||
<option value="">Select location…</option>
|
options={locationOptions}
|
||||||
{locations.map(l => <option key={l.id} value={l.id}>{l.name} ({l.category_name})</option>)}
|
value={form.location_id}
|
||||||
</select>
|
onChange={v => setForm({ ...form, location_id: v })}
|
||||||
|
placeholder="Search locations…"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field"><label>Make / model</label>
|
<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 })} />
|
<input type="text" value={form.serial_no} onChange={e => setForm({ ...form, serial_no: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field"><label>Install date</label>
|
<div className="field">
|
||||||
<input type="date" value={form.install_date} onChange={e => setForm({ ...form, install_date: e.target.value })} />
|
<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>
|
||||||
<div className="field"><label>Notes</label>
|
<div className="field"><label>Notes</label>
|
||||||
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ export default function Summary() {
|
||||||
return c
|
return c
|
||||||
}, [tasks])
|
}, [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)
|
const roomsCategoryExists = categories.some(c => c.is_rooms)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -84,7 +90,7 @@ export default function Summary() {
|
||||||
<div className="chip-bar">
|
<div className="chip-bar">
|
||||||
{categories.map(c => (
|
{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}
|
{c.name}{categoryCounts[c.id] ? ` (${categoryCounts[c.id]})` : ''}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
|
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue