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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue