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
75
frontend/src/components/AssigneeSelect.tsx
Normal file
75
frontend/src/components/AssigneeSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/AuthGate.tsx
Normal file
51
frontend/src/components/AuthGate.tsx
Normal 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>
|
||||
}
|
||||
57
frontend/src/components/Layout.tsx
Normal file
57
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
frontend/src/components/NewTaskModal.tsx
Normal file
204
frontend/src/components/NewTaskModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
379
frontend/src/components/TaskModal.tsx
Normal file
379
frontend/src/components/TaskModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/shared.tsx
Normal file
44
frontend/src/components/shared.tsx
Normal 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`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue