Maintenance log book app — initial scaffold
Multi-department fault log: NewBook-synced room locations + manual locations with categories, six-state task flow (submitted/in progress/ hold-parts/hold-later/temporary fix/fixed), photos per stage, priorities with unusable flag and per-task NewBook out-of-order push, costs on resolve, comment/audit thread, recurring task templates with note-to-template carryover, asset register, contractor register with document attachments, staff/contractor allocation, occupancy-aware summary filter, searchable history with CSV export, email notifications. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
6ca395097e
47 changed files with 6727 additions and 0 deletions
159
frontend/src/pages/Summary.tsx
Normal file
159
frontend/src/pages/Summary.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react'
|
||||
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
|
||||
import { STATUS_LABELS, can } from '../types'
|
||||
import { fetchTasks, fetchLocations, fetchConfig } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import NewTaskModal from '../components/NewTaskModal'
|
||||
import TaskModal from '../components/TaskModal'
|
||||
import { PriorityBadge, StatusBadge, UnusableBadge, ageLabel, formatDate } from '../components/shared'
|
||||
|
||||
const OPEN_STATUSES: TaskStatus[] = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix']
|
||||
|
||||
export default function Summary() {
|
||||
const { user } = useAuth()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
|
||||
const [mineOnly, setMineOnly] = useState(false)
|
||||
const [unoccupiedOnly, setUnoccupiedOnly] = useState(false)
|
||||
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [openTask, setOpenTask] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchTasks({
|
||||
status: statusFilter ?? OPEN_STATUSES.join(','),
|
||||
category_id: categoryFilter ?? undefined,
|
||||
assigned_to: mineOnly ? user.email : undefined,
|
||||
unoccupied: unoccupiedOnly,
|
||||
})
|
||||
.then(t => { setTasks(t); setError(null) })
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [statusFilter, categoryFilter, mineOnly, unoccupiedOnly, user.email])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(() => {})
|
||||
fetchConfig().then(setConfig).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Partial<Record<TaskStatus, number>> = {}
|
||||
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1
|
||||
return c
|
||||
}, [tasks])
|
||||
|
||||
const roomsCategoryExists = categories.some(c => c.is_rooms)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Open maintenance</h1>
|
||||
<button className="btn" onClick={load} disabled={loading}>
|
||||
<RefreshCw size={14} strokeWidth={1.75} /> Refresh
|
||||
</button>
|
||||
{can(user, 'report') && (
|
||||
<button className="btn btn-primary" onClick={() => setShowNew(true)}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Report fault
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="chip-bar">
|
||||
<button className={`chip ${statusFilter === null ? 'active' : ''}`} onClick={() => setStatusFilter(null)}>
|
||||
All open
|
||||
</button>
|
||||
{OPEN_STATUSES.map(s => (
|
||||
<button key={s} className={`chip ${statusFilter === s ? 'active' : ''}`} onClick={() => setStatusFilter(statusFilter === s ? null : s)}>
|
||||
{STATUS_LABELS[s]}{counts[s] ? ` (${counts[s]})` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
|
||||
Mine
|
||||
</button>
|
||||
{roomsCategoryExists && (
|
||||
<button
|
||||
className={`chip ${unoccupiedOnly ? 'active' : ''}`}
|
||||
title="Only rooms with no in-house guest right now (live from NewBook)"
|
||||
onClick={() => setUnoccupiedOnly(!unoccupiedOnly)}
|
||||
>
|
||||
<BedDouble size={13} strokeWidth={1.75} /> Unoccupied rooms only
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && !loading && (
|
||||
<div className="empty-state">No open tasks match these filters.</div>
|
||||
)}
|
||||
|
||||
{tasks.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
|
||||
onClick={() => setOpenTask(t.id)}
|
||||
>
|
||||
<div className="task-card-main">
|
||||
<div className="task-card-title">
|
||||
{t.title}
|
||||
{t.unusable && <UnusableBadge />}
|
||||
{t.newbook_blocked && <span className="badge badge-outline">NB blocked</span>}
|
||||
{t.template_id && <span className="badge badge-outline">Recurring</span>}
|
||||
</div>
|
||||
<div className="task-card-meta">
|
||||
<span>{t.location_name}</span>
|
||||
<span>{t.category_name}</span>
|
||||
{t.asset_name && <span>{t.asset_name}</span>}
|
||||
<span>{ageLabel(t.created_at)} old</span>
|
||||
{t.due_date && (
|
||||
<span className={new Date(t.due_date) < new Date() ? 'overdue' : ''}>
|
||||
due {formatDate(t.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{t.hold_until && <span>held until {formatDate(t.hold_until)}</span>}
|
||||
{t.photo_count > 0 && <span><Camera size={12} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> {t.photo_count}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-card-side">
|
||||
<PriorityBadge priority={t.priority} />
|
||||
<StatusBadge status={t.status} />
|
||||
<span className="muted" style={{ fontSize: 11.5 }}>
|
||||
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showNew && (
|
||||
<NewTaskModal
|
||||
categories={categories}
|
||||
locations={locations}
|
||||
config={config}
|
||||
onClose={() => setShowNew(false)}
|
||||
onCreated={load}
|
||||
/>
|
||||
)}
|
||||
{openTask !== null && (
|
||||
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue