maintenance/frontend/src/components/NewTaskModal.tsx
jtricerolph 3289a31027 Remove NewBook room blocking — app never writes to NewBook
Unsellable flag is in-app visibility only; staff mark rooms out of
order in NewBook through their own process. NewBook use is now
read-only (room sync + occupancy filter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:37:52 +00:00

200 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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 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(() => {})
}
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: unusable ? 4 : 12 }}>
<input type="checkbox" checked={unusable} onChange={e => setUnusable(e.target.checked)} />
Makes this location unusable / unsellable
</label>
{unusable && (
<div className="field-hint" style={{ marginBottom: 12 }}>
This flag is for visibility here only mark the room out of order in NewBook as usual.
</div>
)}
<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>
)
}