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,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>
)
}