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:
jtricerolph 2026-07-03 21:28:57 +00:00
commit 6ca395097e
47 changed files with 6727 additions and 0 deletions

View file

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import type { AssignedType, AuthUser, Contractor } from '../types'
import { fetchAssignableUsers, fetchContractors } from '../api'
export interface Assignment {
assigned_type: AssignedType
assigned_to: string | null
assigned_to_name: string | null
contractor_id: number | null
}
// Staff / Contractor selector. Staff mode lists users with access to this app
// (central auth); contractor mode lists the contractor register.
export default function AssigneeSelect({ value, onChange }: {
value: Assignment
onChange: (a: Assignment) => void
}) {
const [users, setUsers] = useState<AuthUser[]>([])
const [usersError, setUsersError] = useState<string | null>(null)
const [contractors, setContractors] = useState<Contractor[]>([])
useEffect(() => {
fetchAssignableUsers().then(setUsers).catch(err => setUsersError(err.message))
fetchContractors().then(setContractors).catch(() => {})
}, [])
const setType = (t: AssignedType) => {
onChange({ assigned_type: t, assigned_to: null, assigned_to_name: null, contractor_id: null })
}
return (
<div className="field">
<label>Allocated to</label>
<div className="chip-bar" style={{ marginBottom: 6 }}>
<button type="button" className={`chip ${value.assigned_type === 'staff' ? 'active' : ''}`} onClick={() => setType('staff')}>
Staff
</button>
<button type="button" className={`chip ${value.assigned_type === 'contractor' ? 'active' : ''}`} onClick={() => setType('contractor')}>
Contractor
</button>
</div>
{value.assigned_type === 'staff' ? (
<>
<select
value={value.assigned_to ?? ''}
onChange={e => {
const u = users.find(x => x.email === e.target.value)
onChange({ ...value, assigned_to: u?.email ?? null, assigned_to_name: u?.name ?? null, contractor_id: null })
}}
>
<option value="">Unassigned</option>
{users.map(u => <option key={u.email} value={u.email}>{u.name}</option>)}
</select>
{usersError && <div className="field-hint">Could not load staff list: {usersError}</div>}
</>
) : (
<select
value={value.contractor_id ?? ''}
onChange={e => onChange({
...value,
contractor_id: e.target.value ? parseInt(e.target.value) : null,
assigned_to: null,
assigned_to_name: null,
})}
>
<option value="">Select contractor</option>
{contractors.map(c => (
<option key={c.id} value={c.id}>{c.name}{c.company ? `${c.company}` : ''}</option>
))}
</select>
)}
</div>
)
}

View file

@ -0,0 +1,51 @@
import { useEffect, useState, createContext, useContext } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=maintenance', { credentials: 'include' })
.then(r => {
if (r.status === 401 || r.status === 403) {
window.location.href = `/portal?redirect=${encodeURIComponent(window.location.href)}`
return null
}
if (!r.ok) throw new Error(`Auth check failed: ${r.status}`)
return r.json()
})
.then(data => { if (data) setUser(data) })
.catch(err => setError(err.message))
}, [])
if (error) {
return (
<div style={{ padding: 32, color: 'var(--danger)', fontFamily: 'var(--font)' }}>
Authentication error: {error}
</div>
)
}
if (!user) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading
</div>
)
}
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,57 @@
import { NavLink } from 'react-router-dom'
import { Wrench, ClipboardList, History, Boxes, HardHat, Repeat, MapPin, Settings } from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/summary', label: 'Summary', icon: ClipboardList, cap: 'view' },
{ to: '/history', label: 'History', icon: History, cap: 'view' },
{ to: '/assets', label: 'Assets', icon: Boxes, cap: 'view' },
{ to: '/contractors', label: 'Contractors', icon: HardHat, cap: 'view' },
{ to: '/recurring', label: 'Recurring', icon: Repeat, cap: 'view' },
{ to: '/locations', label: 'Locations', icon: MapPin, cap: 'manage_locations' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-logo">
<Wrench size={18} strokeWidth={1.75} />
Maintenance
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON_PROPS} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user">{user.name}</div>
</aside>
<header className="top-bar">
<Wrench size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Maintenance</span>
<nav className="top-bar-nav">
{items.map(({ to, label }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
{label}
</NavLink>
))}
</nav>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

View file

@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from 'react'
import { X, Camera } from 'lucide-react'
import type { Category, Location, Task, Priority, AppConfig, Asset } from '../types'
import { PRIORITIES, PRIORITY_LABELS } from '../types'
import { createTask, fetchTasks, uploadTaskPhoto, blockRoomInNewbook, fetchAssets } from '../api'
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
import { PriorityBadge, StatusBadge } from './shared'
export default function NewTaskModal({ categories, locations, config, onClose, onCreated }: {
categories: Category[]
locations: Location[]
config: AppConfig | null
onClose: () => void
onCreated: () => void
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [locationId, setLocationId] = useState<number | ''>('')
const [assetId, setAssetId] = useState<number | ''>('')
const [priority, setPriority] = useState<Priority>('medium')
const [unusable, setUnusable] = useState(false)
const [dueDate, setDueDate] = useState('')
const [files, setFiles] = useState<File[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [existing, setExisting] = useState<Task[]>([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [assignment, setAssignment] = useState<Assignment>({
assigned_type: (config?.default_assigned_type as Assignment['assigned_type']) || 'staff',
assigned_to: config?.default_assignee || null,
assigned_to_name: config?.default_assignee_name || config?.default_assignee || null,
contractor_id: config?.default_contractor_id ?? null,
})
const location = useMemo(() => locations.find(l => l.id === locationId), [locations, locationId])
const locationAssets = useMemo(
() => assets.filter(a => a.location_id === locationId),
[assets, locationId]
)
useEffect(() => { fetchAssets().then(setAssets).catch(() => {}) }, [])
// Duplicate hint: existing open tasks at the chosen location
useEffect(() => {
if (!locationId) { setExisting([]); return }
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])
async function submit() {
if (!title.trim() || !locationId) { setError('Title and location are required'); return }
setSaving(true)
setError(null)
try {
const task = await createTask({
title: title.trim(),
description: description.trim() || null,
location_id: locationId,
asset_id: assetId || null,
priority,
unusable,
due_date: dueDate || null,
assigned_type: assignment.assigned_type,
assigned_to: assignment.assigned_to,
assigned_to_name: assignment.assigned_to_name,
contractor_id: assignment.contractor_id,
})
for (const file of files) {
await uploadTaskPhoto(task.id, file, 'report').catch(() => {})
}
// Explicit confirm — never block a room in NewBook silently
if (unusable && location?.source === 'newbook') {
const ok = window.confirm(
`Also mark ${location.name} as out of order in NewBook (status: ${config?.newbook_block_status || 'Maintenance'}) so it can't be sold?`
)
if (ok) await blockRoomInNewbook(task.id).catch(err => window.alert(`NewBook block failed: ${err.message}`))
}
onCreated()
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create task')
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>Report a fault</h2>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>Title</label>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Shower dripping" autoFocus />
</div>
<div className="field">
<label>Location</label>
<select value={locationId} onChange={e => { setLocationId(e.target.value ? parseInt(e.target.value) : ''); setAssetId('') }}>
<option value="">Select location</option>
{grouped.map(g => (
<optgroup key={g.category.id} label={g.category.name}>
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</optgroup>
))}
</select>
</div>
{existing.length > 0 && (
<div className="card" style={{ background: 'var(--warn-bg)' }}>
<strong style={{ fontSize: 12.5 }}>Already open at this location:</strong>
{existing.slice(0, 4).map(t => (
<div key={t.id} style={{ fontSize: 12.5, marginTop: 4, display: 'flex', gap: 6, alignItems: 'center' }}>
<StatusBadge status={t.status} /> {t.title}
</div>
))}
</div>
)}
{locationAssets.length > 0 && (
<div className="field">
<label>Asset (optional)</label>
<select value={assetId} onChange={e => setAssetId(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">None</option>
{locationAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
)}
<div className="field">
<label>Description (optional)</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="More detail about the fault…" />
</div>
<div className="field">
<label>Priority</label>
<div className="chip-bar" style={{ marginBottom: 0 }}>
{PRIORITIES.map(p => (
<button key={p} type="button" className={`chip ${priority === p ? 'active' : ''}`} onClick={() => setPriority(p)}>
{PRIORITY_LABELS[p]}
</button>
))}
<PriorityBadge priority={priority} />
</div>
</div>
<label className="field-check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={unusable} onChange={e => setUnusable(e.target.checked)} />
Makes this location unusable / unsellable
</label>
<div className="field-row">
<div className="field">
<label>Due date (optional)</label>
<input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} />
</div>
</div>
<AssigneeSelect value={assignment} onChange={setAssignment} />
<div className="field">
<label>Photos (optional)</label>
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
<Camera size={14} strokeWidth={1.75} />
Add photos
<input
type="file" accept="image/*" multiple capture="environment" style={{ display: 'none' }}
onChange={e => setFiles([...files, ...Array.from(e.target.files || [])])}
/>
</label>
{files.length > 0 && (
<div className="field-hint">
{files.map((f, i) => (
<span key={i} style={{ marginRight: 8 }}>
{f.name} <button className="btn btn-sm" style={{ padding: '0 4px' }} onClick={() => setFiles(files.filter((_, j) => j !== i))}>×</button>
</span>
))}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={saving}>
{saving ? 'Saving…' : 'Submit'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,379 @@
import { useEffect, useState } from 'react'
import {
X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle,
Ban, CheckCircle2, Image as ImageIcon, PoundSterling, UserRound,
} from 'lucide-react'
import type { TaskDetail, TaskStatus, TaskEvent, AuthUser } from '../types'
import { STATUS_LABELS, TRANSITIONS, can } from '../types'
import {
fetchTask, updateTask, resolveTask, addComment, uploadTaskPhoto, deletePhoto,
blockRoomInNewbook, unblockRoomInNewbook, photoUrl, fetchAssignableUsers,
} from '../api'
import { useAuth } from './AuthGate'
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
import { PriorityBadge, StatusBadge, UnusableBadge, formatDate, formatDateTime, ageLabel } from './shared'
function EventIcon({ type }: { type: string }) {
const props = { size: 14, strokeWidth: 1.75 }
switch (type) {
case 'created': return <PlusCircle {...props} />
case 'comment': return <MessageSquare {...props} />
case 'photo': return <ImageIcon {...props} />
case 'cost': return <PoundSterling {...props} />
case 'reassigned': return <UserRound {...props} />
case 'reopened': return <RotateCcw {...props} />
case 'newbook_block': return <Ban {...props} />
case 'newbook_unblock': return <CheckCircle2 {...props} />
default: return <ArrowRight {...props} />
}
}
function eventLine(e: TaskEvent): string {
if (e.event_type === 'status_change' && e.from_status && e.to_status) {
return `${STATUS_LABELS[e.from_status]}${STATUS_LABELS[e.to_status]}`
}
if (e.event_type === 'created') return 'Task created'
if (e.event_type === 'reopened') return `Reopened (was ${e.from_status ? STATUS_LABELS[e.from_status] : ''})`
return ''
}
export default function TaskModal({ taskId, onClose, onChanged }: {
taskId: number
onClose: () => void
onChanged: () => void
}) {
const { user } = useAuth()
const [task, setTask] = useState<TaskDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [comment, setComment] = useState('')
const [addToTemplate, setAddToTemplate] = useState(false)
const [holdUntil, setHoldUntil] = useState('')
const [showReassign, setShowReassign] = useState(false)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [showResolve, setShowResolve] = useState<null | 'temporary_fix' | 'fixed'>(null)
const [resolveUsers, setResolveUsers] = useState<AuthUser[]>([])
const [completedBy, setCompletedBy] = useState('')
const [cost, setCost] = useState('')
const [costNotes, setCostNotes] = useState('')
const [resolveNote, setResolveNote] = useState('')
const [resolveFile, setResolveFile] = useState<File | null>(null)
const load = () => fetchTask(taskId).then(t => {
setTask(t)
setAssignment({
assigned_type: t.assigned_type,
assigned_to: t.assigned_to,
assigned_to_name: t.assigned_to_name,
contractor_id: t.contractor_id,
})
}).catch(err => setError(err.message))
useEffect(() => { load() }, [taskId])
useEffect(() => {
if (showResolve) {
setCompletedBy(user.email)
fetchAssignableUsers().then(setResolveUsers).catch(() => setResolveUsers([]))
}
}, [showResolve])
async function run(fn: () => Promise<unknown>) {
setBusy(true)
setError(null)
try {
await fn()
await load()
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : 'Action failed')
} finally {
setBusy(false)
}
}
if (!task) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
{error ? <div className="error-banner">{error}</div> : 'Loading…'}
</div>
</div>
)
}
const canUpdate = can(user, 'update')
const canResolve = can(user, 'resolve')
const canReport = can(user, 'report')
const showCosts = can(user, 'costs')
const isRoom = task.location_source === 'newbook' && !!task.newbook_site_id
// Non-resolve transitions offered as buttons; resolve statuses open the resolve form
const moves = (TRANSITIONS[task.status] || []).filter(s => !['temporary_fix', 'fixed'].includes(s))
const resolveMoves = (TRANSITIONS[task.status] || []).filter(s => ['temporary_fix', 'fixed'].includes(s)) as Array<'temporary_fix' | 'fixed'>
async function doTransition(status: TaskStatus) {
const body: Record<string, unknown> = { status }
if (status === 'hold_scheduled') {
if (!holdUntil) { setError('Pick a hold-until date first'); return }
body.hold_until = holdUntil
}
await run(() => updateTask(task!.id, body))
}
async function doResolve() {
const status = showResolve!
const u = resolveUsers.find(x => x.email === completedBy)
await run(async () => {
await resolveTask(task!.id, {
status,
completed_by: completedBy || undefined,
completed_by_name: u?.name || undefined,
cost: showCosts && cost ? cost : undefined,
cost_notes: showCosts && costNotes ? costNotes : undefined,
note: resolveNote || undefined,
})
if (resolveFile) await uploadTaskPhoto(task!.id, resolveFile, 'resolution').catch(() => {})
if (task!.newbook_blocked && status === 'fixed') {
const ok = window.confirm('This room is blocked in NewBook — release it now?')
if (ok) await unblockRoomInNewbook(task!.id).catch(() => {})
}
})
setShowResolve(null)
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div style={{ flex: 1 }}>
<h2>#{task.id} {task.title}</h2>
<div className="task-card-meta" style={{ marginTop: 6 }}>
<StatusBadge status={task.status} />
<PriorityBadge priority={task.priority} />
{task.unusable && <UnusableBadge />}
{task.newbook_blocked && <span className="badge badge-outline">Blocked in NewBook</span>}
{task.template_id && <span className="badge badge-outline">Recurring</span>}
</div>
</div>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="task-card-meta" style={{ marginBottom: 10 }}>
<span>{task.location_name} · {task.category_name}</span>
{task.asset_name && <span>Asset: {task.asset_name}</span>}
<span>Reported by {task.created_by_name || task.created_by} · {formatDateTime(task.created_at)} ({ageLabel(task.created_at)} ago)</span>
</div>
<div className="task-card-meta" style={{ marginBottom: 10 }}>
<span>
Allocated: {task.assigned_type === 'contractor'
? `${task.contractor_name || '—'}${task.contractor_company ? ` (${task.contractor_company})` : ''} [contractor]`
: (task.assigned_to_name || 'Unassigned')}
</span>
{task.due_date && <span>Due {formatDate(task.due_date)}</span>}
{task.hold_until && <span>On hold until {formatDate(task.hold_until)}</span>}
{task.completed_at && <span>Completed by {task.completed_by_name} · {formatDateTime(task.completed_at)}</span>}
{showCosts && task.cost != null && <span>Cost £{task.cost}{task.cost_notes ? ` (${task.cost_notes})` : ''}</span>}
</div>
{task.description && <p style={{ whiteSpace: 'pre-wrap', margin: '0 0 12px' }}>{task.description}</p>}
{/* Photos */}
<div className="section-title">Photos</div>
<div className="photo-grid">
{task.photos.map(p => (
<div key={p.id} className="photo-thumb-wrap">
<a href={photoUrl(p.file_path)} target="_blank" rel="noreferrer">
<img className="photo-thumb" src={photoUrl(p.file_path)} alt={p.file_name} title={`${p.stage}${p.uploaded_by}`} />
</a>
{(p.uploaded_by === user.email || canUpdate) && (
<button className="photo-del" onClick={() => run(() => deletePhoto(p.id))} title="Delete photo">×</button>
)}
</div>
))}
{canReport && (
<label className="btn btn-sm" style={{ cursor: 'pointer', alignSelf: 'center' }}>
<Camera size={14} strokeWidth={1.75} /> Add
<input
type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0]
if (f) run(() => uploadTaskPhoto(task.id, f, task.status === 'submitted' ? 'report' : 'progress'))
}}
/>
</label>
)}
</div>
{/* State actions */}
{(canUpdate || canResolve) && task.status !== 'fixed' && !showResolve && (
<>
<div className="section-title">Actions</div>
<div className="chip-bar">
{canUpdate && moves.map(s => (
<button key={s} className="btn btn-sm" disabled={busy} onClick={() => doTransition(s)}>
<ArrowRight size={13} strokeWidth={1.75} /> {STATUS_LABELS[s]}
</button>
))}
{canResolve && resolveMoves.map(s => (
<button key={s} className="btn btn-sm btn-primary" disabled={busy} onClick={() => setShowResolve(s)}>
<CheckCircle2 size={13} strokeWidth={1.75} /> {STATUS_LABELS[s]}
</button>
))}
</div>
{canUpdate && (TRANSITIONS[task.status] || []).includes('hold_scheduled') && (
<div className="field-row" style={{ maxWidth: 260 }}>
<div className="field">
<label>Hold until (for Hold Later Date)</label>
<input type="date" value={holdUntil} onChange={e => setHoldUntil(e.target.value)} />
</div>
</div>
)}
</>
)}
{canUpdate && task.status === 'fixed' && (
<div className="chip-bar">
<button className="btn btn-sm" disabled={busy} onClick={() => doTransition('submitted')}>
<RotateCcw size={13} strokeWidth={1.75} /> Reopen
</button>
</div>
)}
{/* NewBook room block */}
{canUpdate && isRoom && task.status !== 'fixed' && (
<div className="chip-bar">
{task.newbook_blocked ? (
<button className="btn btn-sm" disabled={busy} onClick={() => run(() => unblockRoomInNewbook(task.id))}>
<CheckCircle2 size={13} strokeWidth={1.75} /> Release room in NewBook
</button>
) : (
<button className="btn btn-sm" disabled={busy} onClick={() => {
if (window.confirm(`Mark ${task.location_name} out of order in NewBook so it can't be sold?`)) {
run(() => blockRoomInNewbook(task.id))
}
}}>
<Ban size={13} strokeWidth={1.75} /> Block room in NewBook
</button>
)}
</div>
)}
{/* Resolve form */}
{showResolve && (
<div className="card" style={{ background: 'var(--ok-bg)' }}>
<div className="section-title" style={{ marginTop: 0 }}>
Mark as {STATUS_LABELS[showResolve]}
</div>
<div className="field">
<label>Completed by</label>
<select value={completedBy} onChange={e => setCompletedBy(e.target.value)}>
<option value={user.email}>{user.name} (me)</option>
{resolveUsers.filter(u2 => u2.email !== user.email).map(u2 => (
<option key={u2.email} value={u2.email}>{u2.name}</option>
))}
</select>
</div>
{showCosts && (
<div className="field-row">
<div className="field">
<label>Cost / value (optional)</label>
<input type="number" step="0.01" min="0" value={cost} onChange={e => setCost(e.target.value)} placeholder="0.00" />
</div>
<div className="field">
<label>Cost notes</label>
<input type="text" value={costNotes} onChange={e => setCostNotes(e.target.value)} placeholder="e.g. new valve + labour" />
</div>
</div>
)}
<div className="field">
<label>Note (optional)</label>
<textarea value={resolveNote} onChange={e => setResolveNote(e.target.value)} placeholder="How was it fixed?" />
</div>
<div className="field">
<label>Photo of the fix? (optional but encouraged)</label>
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
<Camera size={14} strokeWidth={1.75} /> {resolveFile ? resolveFile.name : 'Add photo'}
<input
type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
onChange={e => setResolveFile(e.target.files?.[0] || null)}
/>
</label>
</div>
<div className="modal-actions">
<button className="btn" onClick={() => setShowResolve(null)}>Cancel</button>
<button className="btn btn-primary" disabled={busy} onClick={doResolve}>
{busy ? 'Saving…' : `Confirm ${STATUS_LABELS[showResolve]}`}
</button>
</div>
</div>
)}
{/* Reassign */}
{canUpdate && task.status !== 'fixed' && (
<>
<button className="btn btn-sm" style={{ marginBottom: 8 }} onClick={() => setShowReassign(!showReassign)}>
<UserRound size={13} strokeWidth={1.75} /> Reallocate
</button>
{showReassign && assignment && (
<div className="card">
<AssigneeSelect value={assignment} onChange={setAssignment} />
<div className="modal-actions">
<button className="btn btn-sm" onClick={() => setShowReassign(false)}>Cancel</button>
<button className="btn btn-sm btn-primary" disabled={busy} onClick={() => {
run(() => updateTask(task.id, { ...assignment }))
setShowReassign(false)
}}>Save</button>
</div>
</div>
)}
</>
)}
{/* Thread */}
<div className="section-title">Activity</div>
<div className="timeline">
{task.events.map(e => (
<div key={e.id} className="timeline-item">
<span className="timeline-icon"><EventIcon type={e.event_type} /></span>
<div className="timeline-body">
{eventLine(e) && <div><strong>{eventLine(e)}</strong></div>}
{e.note && <div className="timeline-note">{e.note}</div>}
<div className="timeline-meta">{e.user_name || '—'} · {formatDateTime(e.created_at)}</div>
</div>
</div>
))}
</div>
{canReport && (
<div className="field">
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Add a note / update…"
style={{ minHeight: 52 }}
/>
{task.template_id && (
<label className="field-check" style={{ margin: '6px 0' }}>
<input type="checkbox" checked={addToTemplate} onChange={e => setAddToTemplate(e.target.checked)} />
Also add this note to the recurring template (shows on future occurrences)
</label>
)}
<div className="modal-actions" style={{ marginTop: 6 }}>
<button className="btn btn-sm btn-primary" disabled={busy || !comment.trim()} onClick={() => {
run(() => addComment(task.id, comment.trim(), addToTemplate))
setComment('')
setAddToTemplate(false)
}}>
<MessageSquare size={13} strokeWidth={1.75} /> Add note
</button>
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,44 @@
import { AlertTriangle } from 'lucide-react'
import type { Priority, TaskStatus } from '../types'
import { PRIORITY_LABELS, STATUS_LABELS } from '../types'
export function PriorityBadge({ priority }: { priority: Priority }) {
return <span className={`badge badge-prio-${priority}`}>{PRIORITY_LABELS[priority]}</span>
}
export function StatusBadge({ status }: { status: TaskStatus }) {
return <span className={`badge badge-st-${status}`}>{STATUS_LABELS[status]}</span>
}
export function UnusableBadge() {
return (
<span className="badge badge-unusable">
<AlertTriangle size={11} strokeWidth={1.75} />
Unusable
</span>
)
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' +
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
export function daysOpen(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 86400000)
}
export function ageLabel(createdAt: string): string {
const days = daysOpen(createdAt)
if (days === 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}