Merge asset recurring templates into the main Recurring/Summary pages
Assets.tsx had its own bare-bones template-creation form (title/interval/ next_due only, no description/priority-choice/assignee/schedule_mode/ lead_days/group) with zero edit or delete path — a linked recurring task could only ever be created from here, never managed. The backend already sourced the asset detail's tasks/templates straight from the main tasks and task_templates tables (no separate storage), so the fix is entirely frontend: stop duplicating the editor, link back to the real one. - Assets.tsx: removed the bespoke ServiceTaskForm entirely. Each linked template row now navigates to /recurring?edit=<id> (opens that template's full edit form there, including delete). "+ Add" navigates to /recurring?new_asset=&new_location= for a prefilled New Template form. Added a matching "+ Report fault" action navigating to /summary?new_asset=&new_location= for one-off tasks, since there was previously no way to create a plain task from the Assets page at all. Existing tasks still open in-place via TaskModal, unchanged. - Recurring.tsx / Summary.tsx: read those query params on mount, open the appropriate form/modal prefilled, then strip the params from the URL (replace, no history pollution). - NewTaskModal.tsx + Recurring.tsx template form: asset picker is no longer gated behind picking a location first — it always shows the full register (grouped by location) or the location-filtered list once one's chosen, and selecting an asset now back-fills its location automatically. Makes tagging routine servicing to a specific asset (fixed or floating recurring) the natural way in, for both one-off and recurring tasks. - Asset list/detail counts (open tasks, recurring templates) are unchanged — still sourced from the same main-table queries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d9b01a5981
commit
2d2541576d
4 changed files with 136 additions and 84 deletions
|
|
@ -7,17 +7,19 @@ import AssigneeSelect, { type Assignment } from './AssigneeSelect'
|
||||||
import SearchSelect from './SearchSelect'
|
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, initialLocationId, initialAssetId }: {
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
locations: Location[]
|
locations: Location[]
|
||||||
config: AppConfig | null
|
config: AppConfig | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onCreated: () => void
|
onCreated: () => void
|
||||||
|
initialLocationId?: number
|
||||||
|
initialAssetId?: number
|
||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [locationId, setLocationId] = useState<number | ''>('')
|
const [locationId, setLocationId] = useState<number | ''>(initialLocationId ?? '')
|
||||||
const [assetId, setAssetId] = useState<number | ''>('')
|
const [assetId, setAssetId] = useState<number | ''>(initialAssetId ?? '')
|
||||||
const [priority, setPriority] = useState<Priority>('medium')
|
const [priority, setPriority] = useState<Priority>('medium')
|
||||||
const [unusable, setUnusable] = useState(false)
|
const [unusable, setUnusable] = useState(false)
|
||||||
const [dueDate, setDueDate] = useState('')
|
const [dueDate, setDueDate] = useState('')
|
||||||
|
|
@ -40,6 +42,27 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
|
||||||
[assets, locationId]
|
[assets, locationId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Asset picker: filtered to the chosen location once one's picked, otherwise the
|
||||||
|
// full register grouped by location — so an asset can be picked first, and doing
|
||||||
|
// so fills in its location automatically.
|
||||||
|
const assetGroups = useMemo(() => {
|
||||||
|
const pool = locationId ? locationAssets : assets
|
||||||
|
const byLocation = new Map<string, Asset[]>()
|
||||||
|
for (const a of pool) {
|
||||||
|
if (!byLocation.has(a.location_name)) byLocation.set(a.location_name, [])
|
||||||
|
byLocation.get(a.location_name)!.push(a)
|
||||||
|
}
|
||||||
|
return [...byLocation.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
}, [assets, locationAssets, locationId])
|
||||||
|
|
||||||
|
function pickAsset(id: number | '') {
|
||||||
|
setAssetId(id)
|
||||||
|
if (id) {
|
||||||
|
const a = assets.find(x => x.id === id)
|
||||||
|
if (a) setLocationId(a.location_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchAssets().then(setAssets).catch(() => {}) }, [])
|
useEffect(() => { fetchAssets().then(setAssets).catch(() => {}) }, [])
|
||||||
|
|
||||||
// Duplicate hint: existing open tasks at the chosen location
|
// Duplicate hint: existing open tasks at the chosen location
|
||||||
|
|
@ -127,13 +150,18 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{locationAssets.length > 0 && (
|
{assets.length > 0 && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Asset (optional)</label>
|
<label>Asset (optional)</label>
|
||||||
<select value={assetId} onChange={e => setAssetId(e.target.value ? parseInt(e.target.value) : '')}>
|
<select value={assetId} onChange={e => pickAsset(e.target.value ? parseInt(e.target.value) : '')}>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{locationAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
{assetGroups.map(([locName, as]) => (
|
||||||
|
<optgroup key={locName} label={locName}>
|
||||||
|
{as.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{!locationId && <div className="field-hint">Picking an asset fills in its location automatically.</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Pencil, 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 } from '../api'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import SearchSelect from '../components/SearchSelect'
|
import SearchSelect from '../components/SearchSelect'
|
||||||
import TaskModal from '../components/TaskModal'
|
import TaskModal from '../components/TaskModal'
|
||||||
|
|
@ -20,13 +21,6 @@ interface AssetForm {
|
||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceTaskForm {
|
|
||||||
title: string
|
|
||||||
interval_value: string
|
|
||||||
interval_unit: 'days' | 'weeks' | 'months'
|
|
||||||
next_due: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY: AssetForm = {
|
const EMPTY: AssetForm = {
|
||||||
name: '', location_id: '', make_model: '', serial_no: '',
|
name: '', location_id: '', make_model: '', serial_no: '',
|
||||||
install_date: '', install_date_unknown: false, notes: '', active: true,
|
install_date: '', install_date_unknown: false, notes: '', active: true,
|
||||||
|
|
@ -34,16 +28,17 @@ const EMPTY: AssetForm = {
|
||||||
|
|
||||||
export default function Assets() {
|
export default function Assets() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [assets, setAssets] = useState<Asset[]>([])
|
const [assets, setAssets] = useState<Asset[]>([])
|
||||||
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 canManageTemplates = can(user, 'manage_templates')
|
||||||
|
const canReport = can(user, 'report')
|
||||||
|
|
||||||
const locationOptions = useMemo(() =>
|
const locationOptions = useMemo(() =>
|
||||||
locations.map(l => ({ value: l.id, label: l.name, group: l.category_name })),
|
locations.map(l => ({ value: l.id, label: l.name, group: l.category_name })),
|
||||||
|
|
@ -78,30 +73,6 @@ 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">
|
||||||
|
|
@ -174,61 +145,35 @@ export default function Assets() {
|
||||||
|
|
||||||
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className="section-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
Recurring service tasks
|
Recurring service tasks
|
||||||
{canManageTemplates && !serviceForm && (
|
{canManageTemplates && (
|
||||||
<button className="btn btn-sm" onClick={() => setServiceForm({ title: '', interval_value: '1', interval_unit: 'months', next_due: '' })}>
|
<button className="btn btn-sm" onClick={() => navigate(`/recurring?new_asset=${detail.id}&new_location=${detail.location_id}`)}>
|
||||||
<Plus size={12} strokeWidth={1.75} /> Add
|
<Plus size={12} strokeWidth={1.75} /> Add
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 clickable" style={{ marginBottom: 4, cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/recurring?edit=${tp.id}`)}
|
||||||
|
>
|
||||||
<span>{tp.title}</span>
|
<span>{tp.title}</span>
|
||||||
<span>every {tp.interval_value} {tp.interval_unit}</span>
|
<span>every {tp.interval_value} {tp.interval_unit}</span>
|
||||||
<span>next due {formatDate(tp.next_due)}</span>
|
<span>next due {formatDate(tp.next_due)}</span>
|
||||||
{!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 && (
|
{detail.templates.length === 0 && (
|
||||||
<div className="muted" style={{ fontSize: 12.5 }}>No recurring service tasks for this asset.</div>
|
<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" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
Task history
|
||||||
|
{canReport && (
|
||||||
|
<button className="btn btn-sm" onClick={() => navigate(`/summary?new_asset=${detail.id}&new_location=${detail.location_id}`)}>
|
||||||
|
<Plus size={12} strokeWidth={1.75} /> Report fault
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{detail.tasks.length === 0 && <div className="muted">No tasks logged against this asset.</div>}
|
{detail.tasks.length === 0 && <div className="muted">No tasks logged against this asset.</div>}
|
||||||
{detail.tasks.map(t => (
|
{detail.tasks.map(t => (
|
||||||
<div key={t.id} className="timeline-item clickable" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
|
<div key={t.id} className="timeline-item clickable" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { Plus, X, Play, Copy, ChevronRight, Trash2 } from 'lucide-react'
|
import { Plus, X, Play, Copy, ChevronRight, Trash2 } from 'lucide-react'
|
||||||
import type { Template, TemplateGroup, Category, Location, Asset, Priority, ScheduleMode } from '../types'
|
import type { Template, TemplateGroup, Category, Location, Asset, Priority, ScheduleMode } from '../types'
|
||||||
import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
|
import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
|
||||||
|
|
@ -42,6 +43,7 @@ const EMPTY: TemplateForm = {
|
||||||
|
|
||||||
export default function Recurring() {
|
export default function Recurring() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [groups, setGroups] = useState<TemplateGroup[]>([])
|
const [groups, setGroups] = useState<TemplateGroup[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
|
@ -64,6 +66,30 @@ export default function Recurring() {
|
||||||
fetchAssets().then(setAssets).catch(() => {})
|
fetchAssets().then(setAssets).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Deep links from the Assets page: ?edit=<templateId> opens that template's
|
||||||
|
// edit form (waits for templates to load if it fires before they arrive);
|
||||||
|
// ?new_asset=&new_location= opens a prefilled New Template form.
|
||||||
|
useEffect(() => {
|
||||||
|
const editId = searchParams.get('edit')
|
||||||
|
const newAsset = searchParams.get('new_asset')
|
||||||
|
const newLocation = searchParams.get('new_location')
|
||||||
|
if (editId) {
|
||||||
|
const t = templates.find(x => x.id === parseInt(editId))
|
||||||
|
if (t) {
|
||||||
|
openEdit(t)
|
||||||
|
setSearchParams(prev => { const next = new URLSearchParams(prev); next.delete('edit'); return next }, { replace: true })
|
||||||
|
}
|
||||||
|
} else if (newAsset || newLocation) {
|
||||||
|
setForm({ ...EMPTY, asset_id: newAsset ? parseInt(newAsset) : '', location_id: newLocation ? parseInt(newLocation) : '' })
|
||||||
|
setSearchParams(prev => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
next.delete('new_asset'); next.delete('new_location')
|
||||||
|
return next
|
||||||
|
}, { replace: true })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [templates, searchParams])
|
||||||
|
|
||||||
const locationGroups = useMemo(() => categories.map(c => ({
|
const locationGroups = useMemo(() => categories.map(c => ({
|
||||||
category: c,
|
category: c,
|
||||||
locations: locations.filter(l => l.category_id === c.id),
|
locations: locations.filter(l => l.category_id === c.id),
|
||||||
|
|
@ -74,6 +100,29 @@ export default function Recurring() {
|
||||||
[assets, form?.location_id]
|
[assets, form?.location_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Asset picker for the single-location form: filtered to the chosen location
|
||||||
|
// once one's picked, otherwise the full register grouped by location — so an
|
||||||
|
// asset can be picked first, and doing so fills in its location automatically.
|
||||||
|
const formAssetGroups = useMemo(() => {
|
||||||
|
const pool = form?.location_id ? formAssets : assets
|
||||||
|
const byLocation = new Map<string, Asset[]>()
|
||||||
|
for (const a of pool) {
|
||||||
|
if (!byLocation.has(a.location_name)) byLocation.set(a.location_name, [])
|
||||||
|
byLocation.get(a.location_name)!.push(a)
|
||||||
|
}
|
||||||
|
return [...byLocation.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
}, [assets, formAssets, form?.location_id])
|
||||||
|
|
||||||
|
function pickFormAsset(id: number | '') {
|
||||||
|
if (!form) return
|
||||||
|
if (id) {
|
||||||
|
const a = assets.find(x => x.id === id)
|
||||||
|
setForm({ ...form, asset_id: id, location_id: a ? a.location_id : form.location_id })
|
||||||
|
} else {
|
||||||
|
setForm({ ...form, asset_id: '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { groupSections, ungrouped } = useMemo(() => {
|
const { groupSections, ungrouped } = useMemo(() => {
|
||||||
const byGroup = new Map<string, Template[]>()
|
const byGroup = new Map<string, Template[]>()
|
||||||
const solo: Template[] = []
|
const solo: Template[] = []
|
||||||
|
|
@ -344,12 +393,17 @@ export default function Recurring() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{formAssets.length > 0 && (
|
{assets.length > 0 && (
|
||||||
<div className="field"><label>Asset (optional)</label>
|
<div className="field"><label>Asset (optional)</label>
|
||||||
<select value={form.asset_id} onChange={e => setForm({ ...form, asset_id: e.target.value ? parseInt(e.target.value) : '' })}>
|
<select value={form.asset_id} onChange={e => pickFormAsset(e.target.value ? parseInt(e.target.value) : '')}>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{formAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
{formAssetGroups.map(([locName, as]) => (
|
||||||
|
<optgroup key={locName} label={locName}>
|
||||||
|
{as.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{!form.location_id && <div className="field-hint">Picking an asset fills in its location automatically.</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { Plus, RefreshCw, Camera, BedDouble, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
import { Plus, RefreshCw, Camera, BedDouble, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types'
|
import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types'
|
||||||
import { STATUS_LABELS, can } from '../types'
|
import { STATUS_LABELS, can } from '../types'
|
||||||
|
|
@ -47,7 +48,9 @@ export default function Summary() {
|
||||||
setSortMode(prev => SORT_CYCLE[(SORT_CYCLE.indexOf(prev) + 1) % SORT_CYCLE.length])
|
setSortMode(prev => SORT_CYCLE[(SORT_CYCLE.indexOf(prev) + 1) % SORT_CYCLE.length])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [showNew, setShowNew] = useState(false)
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [newTaskPreset, setNewTaskPreset] = useState<{ locationId?: number; assetId?: number }>({})
|
||||||
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 roomsCat = useMemo(() => categories.find(c => c.is_rooms) ?? null, [categories])
|
||||||
|
|
@ -80,6 +83,26 @@ export default function Summary() {
|
||||||
fetchConfig().then(setConfig).catch(() => {})
|
fetchConfig().then(setConfig).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Deep link from the Assets page: ?new_asset=&new_location= opens a prefilled
|
||||||
|
// Report fault form instead of the asset page having its own creation form.
|
||||||
|
useEffect(() => {
|
||||||
|
const newAsset = searchParams.get('new_asset')
|
||||||
|
const newLocation = searchParams.get('new_location')
|
||||||
|
if (newAsset || newLocation) {
|
||||||
|
setNewTaskPreset({
|
||||||
|
locationId: newLocation ? parseInt(newLocation) : undefined,
|
||||||
|
assetId: newAsset ? parseInt(newAsset) : undefined,
|
||||||
|
})
|
||||||
|
setShowNew(true)
|
||||||
|
setSearchParams(prev => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
next.delete('new_asset'); next.delete('new_location')
|
||||||
|
return next
|
||||||
|
}, { replace: true })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const c: Partial<Record<TaskStatus, number>> = {}
|
const c: Partial<Record<TaskStatus, number>> = {}
|
||||||
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1
|
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1
|
||||||
|
|
@ -141,7 +164,7 @@ export default function Summary() {
|
||||||
<RefreshCw size={14} strokeWidth={1.75} /> Refresh
|
<RefreshCw size={14} strokeWidth={1.75} /> Refresh
|
||||||
</button>
|
</button>
|
||||||
{can(user, 'report') && (
|
{can(user, 'report') && (
|
||||||
<button className="btn btn-primary" onClick={() => setShowNew(true)}>
|
<button className="btn btn-primary" onClick={() => { setNewTaskPreset({}); setShowNew(true) }}>
|
||||||
<Plus size={14} strokeWidth={1.75} /> Report fault
|
<Plus size={14} strokeWidth={1.75} /> Report fault
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -287,6 +310,8 @@ export default function Summary() {
|
||||||
categories={categories}
|
categories={categories}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
config={config}
|
config={config}
|
||||||
|
initialLocationId={newTaskPreset.locationId}
|
||||||
|
initialAssetId={newTaskPreset.assetId}
|
||||||
onClose={() => setShowNew(false)}
|
onClose={() => setShowNew(false)}
|
||||||
onCreated={load}
|
onCreated={load}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue