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

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_HOTEL_NAME
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html/maintenance
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

16
frontend/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="theme-color" content="#b45309" />
<title>Maintenance</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
frontend/nginx.conf Normal file
View file

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
client_max_body_size 12m;
location /maintenance/api/auth/ {
proxy_pass http://10.10.10.101:3001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /maintenance/api/ {
proxy_pass http://backend:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location /maintenance/health {
proxy_pass http://backend:3001/health;
}
location ~* /maintenance/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /maintenance/ {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /maintenance/index.html;
}
location = / {
return 301 /maintenance/;
}
}

1901
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
frontend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "hnf-maintenance-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

32
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,32 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import Layout from './components/Layout'
import Summary from './pages/Summary'
import HistoryPage from './pages/History'
import Assets from './pages/Assets'
import Contractors from './pages/Contractors'
import Recurring from './pages/Recurring'
import Locations from './pages/Locations'
import Settings from './pages/Settings'
export default function App() {
return (
<BrowserRouter basename="/maintenance">
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/summary" replace />} />
<Route path="/summary" element={<Summary />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/assets" element={<Assets />} />
<Route path="/contractors" element={<Contractors />} />
<Route path="/recurring" element={<Recurring />} />
<Route path="/locations" element={<Locations />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/summary" replace />} />
</Routes>
</Layout>
</AuthGate>
</BrowserRouter>
)
}

218
frontend/src/api.ts Normal file
View file

@ -0,0 +1,218 @@
import type {
Task, TaskDetail, TaskStatus, Location, Category, Asset, AssetDetail,
Contractor, ContractorDetail, ContractorDoc, Template, AppConfig, AuthUser, TaskPhoto,
} from './types'
const BASE = '/maintenance/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
// Locations
export function fetchLocations(): Promise<{ categories: Category[]; locations: Location[] }> {
return request('/locations')
}
export function createLocation(body: { name: string; category_id: number }): Promise<Location> {
return request('/locations', { method: 'POST', body: JSON.stringify(body) })
}
export function updateLocation(id: number, body: Partial<Location>): Promise<Location> {
return request(`/locations/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function syncNewbookRooms(): Promise<{ ok: boolean; created: number; updated: number; total: number }> {
return request('/locations/sync-newbook', { method: 'POST' })
}
export function createCategory(body: { name: string; sort_order?: number; is_rooms?: boolean }): Promise<Category> {
return request('/categories', { method: 'POST', body: JSON.stringify(body) })
}
export function updateCategory(id: number, body: Partial<Category>): Promise<Category> {
return request(`/categories/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteCategory(id: number): Promise<{ ok: boolean }> {
return request(`/categories/${id}`, { method: 'DELETE' })
}
// Tasks
export interface TaskFilters {
status?: string
category_id?: number
location_id?: number
asset_id?: number
priority?: string
assigned_to?: string
contractor_id?: number
q?: string
unoccupied?: boolean
}
export function fetchTasks(filters: TaskFilters = {}): Promise<Task[]> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return request(`/tasks${qs ? `?${qs}` : ''}`)
}
export function fetchTask(id: number): Promise<TaskDetail> {
return request(`/tasks/${id}`)
}
export function createTask(body: Record<string, unknown>): Promise<Task> {
return request('/tasks', { method: 'POST', body: JSON.stringify(body) })
}
export function updateTask(id: number, body: Record<string, unknown>): Promise<Task> {
return request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function resolveTask(id: number, body: {
status: 'temporary_fix' | 'fixed'
completed_by?: string
completed_by_name?: string
cost?: string
cost_notes?: string
note?: string
}): Promise<Task> {
return request(`/tasks/${id}/resolve`, { method: 'POST', body: JSON.stringify(body) })
}
export function addComment(id: number, note: string, addToTemplate = false): Promise<{ ok: boolean; added_to_template: boolean }> {
return request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify({ note, add_to_template: addToTemplate }) })
}
export function blockRoomInNewbook(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}/newbook-block`, { method: 'POST' })
}
export function unblockRoomInNewbook(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}/newbook-unblock`, { method: 'POST' })
}
export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[] }> {
return request('/occupancy')
}
// Photos — multipart, so no JSON content-type header
export async function uploadTaskPhoto(taskId: number, file: File, stage: string): Promise<TaskPhoto> {
const form = new FormData()
form.append('stage', stage)
form.append('file', file)
const res = await fetch(`${BASE}/tasks/${taskId}/photos`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function deletePhoto(id: number): Promise<{ ok: boolean }> {
return request(`/photos/${id}`, { method: 'DELETE' })
}
export function photoUrl(filePath: string): string {
return `${BASE}/uploads${filePath}`
}
// History
export interface HistoryFilters {
q?: string
from?: string
to?: string
category_id?: number
location_id?: number
asset_id?: number
include_temporary?: boolean
limit?: number
offset?: number
}
export function fetchHistory(filters: HistoryFilters = {}): Promise<{
tasks: Task[]
totals: { count: number; total_cost: string | null; avg_days_to_fix: string | null }
}> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return request(`/history${qs ? `?${qs}` : ''}`)
}
export function historyExportUrl(filters: HistoryFilters = {}): string {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '' && v !== false) params.set(k, String(v))
}
const qs = params.toString()
return `${BASE}/history/export${qs ? `?${qs}` : ''}`
}
// Assets
export function fetchAssets(includeInactive = false): Promise<Asset[]> {
return request(`/assets${includeInactive ? '?include_inactive=true' : ''}`)
}
export function fetchAsset(id: number): Promise<AssetDetail> {
return request(`/assets/${id}`)
}
export function createAsset(body: Record<string, unknown>): Promise<Asset> {
return request('/assets', { method: 'POST', body: JSON.stringify(body) })
}
export function updateAsset(id: number, body: Record<string, unknown>): Promise<Asset> {
return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
// Contractors
export function fetchContractors(includeInactive = false): Promise<Contractor[]> {
return request(`/contractors${includeInactive ? '?include_inactive=true' : ''}`)
}
export function fetchContractor(id: number): Promise<ContractorDetail> {
return request(`/contractors/${id}`)
}
export function createContractor(body: Record<string, unknown>): Promise<Contractor> {
return request('/contractors', { method: 'POST', body: JSON.stringify(body) })
}
export function updateContractor(id: number, body: Record<string, unknown>): Promise<Contractor> {
return request(`/contractors/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export async function uploadContractorDoc(contractorId: number, file: File, docType: string, expiryDate: string): Promise<ContractorDoc> {
const form = new FormData()
form.append('doc_type', docType)
form.append('expiry_date', expiryDate)
form.append('file', file)
const res = await fetch(`${BASE}/contractors/${contractorId}/docs`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function deleteContractorDoc(id: number): Promise<{ ok: boolean }> {
return request(`/contractor-docs/${id}`, { method: 'DELETE' })
}
// Templates (recurring tasks)
export function fetchTemplates(): Promise<Template[]> {
return request('/templates')
}
export function createTemplate(body: Record<string, unknown>): Promise<Template> {
return request('/templates', { method: 'POST', body: JSON.stringify(body) })
}
export function updateTemplate(id: number, body: Record<string, unknown>): Promise<Template> {
return request(`/templates/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function runDueTemplates(): Promise<{ ok: boolean; spawned: number }> {
return request('/templates/run-due', { method: 'POST' })
}
// Config
export function fetchConfig(): Promise<AppConfig> {
return request('/config')
}
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
}
// Assignable staff users — served by the central auth service through the nginx auth proxy
export async function fetchAssignableUsers(): Promise<AuthUser[]> {
const res = await fetch('/maintenance/api/auth/users?app=maintenance', { credentials: 'include' })
if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
return res.json()
}

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`
}

377
frontend/src/index.css Normal file
View file

@ -0,0 +1,377 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* App theme + semantic tokens */
:root {
--app-primary: #b45309;
--app-primary-light: #d97706;
--prio-low: #64748b;
--prio-medium: #2563eb;
--prio-high: #d97706;
--prio-urgent: #dc2626;
--st-submitted: #2563eb;
--st-in-progress: #7c3aed;
--st-hold: #64748b;
--st-temporary: #d97706;
--st-fixed: #16a34a;
--danger-bg: #fef2f2;
--warn-bg: #fffbeb;
--ok-bg: #f0fdf4;
--sidebar-w: 240px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── App shell ─────────────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 16px 12px;
color: var(--gold);
font-size: 13px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-logo svg { opacity: .8; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--surface-2);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
display: none;
height: var(--topbar-h);
background: var(--navy);
color: var(--text);
align-items: center;
padding: 0 12px;
gap: 10px;
flex-shrink: 0;
}
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
.top-bar-nav::-webkit-scrollbar { display: none; }
.top-bar-nav a {
color: var(--text-muted);
padding: 6px 8px;
border-radius: 6px;
text-decoration: none;
font-size: 12px;
white-space: nowrap;
}
.top-bar-nav a.active { color: var(--gold); }
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@media (max-width: 768px) {
.sidebar { display: none; }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
}
/* ── Page chrome ───────────────────────────────────────────── */
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text-dark);
border-radius: var(--radius);
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: var(--font);
transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
/* ── Forms ─────────────────────────────────────────────────── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="date"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
/* ── Cards & lists ─────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px 16px;
margin-bottom: 10px;
}
.task-card {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
transition: box-shadow .12s;
}
.task-card:hover { box-shadow: var(--shadow-md); }
.task-card.urgent { border-left: 4px solid var(--prio-urgent); }
.task-card.high { border-left: 4px solid var(--prio-high); }
.task-card.unusable { background: var(--danger-bg); }
.task-card-main { flex: 1; min-width: 0; }
.task-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.task-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.task-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
/* ── Badges ────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
.badge-prio-low { background: var(--prio-low); }
.badge-prio-medium { background: var(--prio-medium); }
.badge-prio-high { background: var(--prio-high); }
.badge-prio-urgent { background: var(--prio-urgent); }
.badge-st-submitted { background: var(--st-submitted); }
.badge-st-in_progress { background: var(--st-in-progress); }
.badge-st-hold_parts, .badge-st-hold_scheduled { background: var(--st-hold); }
.badge-st-temporary_fix { background: var(--st-temporary); }
.badge-st-fixed { background: var(--st-fixed); }
.badge-outline {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-mid);
font-weight: 500;
}
.badge-unusable { background: var(--danger); }
/* ── Filter chips ──────────────────────────────────────────── */
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--card-border);
background: var(--card-bg);
cursor: pointer;
font-size: 12px;
color: var(--text-mid);
user-select: none;
font-family: var(--font);
transition: all .12s;
}
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
/* ── Modal ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15,15,32,.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
z-index: 100;
overflow-y: auto;
}
.modal {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 680px;
padding: 20px;
margin: auto 0;
}
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-mid);
padding: 2px;
display: flex;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
/* ── Timeline / thread ─────────────────────────────────────── */
.timeline { margin: 8px 0; }
.timeline-item {
display: flex;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--card-border);
font-size: 13px;
}
.timeline-item:last-child { border-bottom: none; }
.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; }
.timeline-body { flex: 1; min-width: 0; }
.timeline-note { white-space: pre-wrap; }
.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; }
/* ── Photos ────────────────────────────────────────────────── */
.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
.photo-thumb {
width: 84px;
height: 84px;
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--card-border);
cursor: pointer;
}
.photo-thumb-wrap { position: relative; }
.photo-del {
position: absolute;
top: -6px;
right: -6px;
background: var(--danger);
color: #fff;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
/* ── Tables ────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left;
padding: 9px 12px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
table.data tr:last-child td { border-bottom: none; }
table.data tr.clickable { cursor: pointer; }
table.data tr.clickable:hover td { background: var(--body-bg); }
/* ── Stats strip ───────────────────────────────────────────── */
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.stat-box {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 10px 16px;
min-width: 110px;
}
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
/* ── Misc ──────────────────────────────────────────────────── */
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
.error-banner {
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
.overdue { color: var(--danger); font-weight: 600; }

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -0,0 +1,206 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, X } from 'lucide-react'
import type { Asset, AssetDetail, Location } from '../types'
import { can } from '../types'
import { fetchAssets, fetchAsset, createAsset, updateAsset, fetchLocations } from '../api'
import { useAuth } from '../components/AuthGate'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
interface AssetForm {
id?: number
name: string
location_id: number | ''
make_model: string
serial_no: string
install_date: string
notes: string
active: boolean
}
const EMPTY: AssetForm = { name: '', location_id: '', make_model: '', serial_no: '', install_date: '', notes: '', active: true }
export default function Assets() {
const { user } = useAuth()
const [assets, setAssets] = useState<Asset[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [detail, setDetail] = useState<AssetDetail | null>(null)
const [form, setForm] = useState<AssetForm | null>(null)
const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(null)
const canManage = can(user, 'manage_assets')
const load = useCallback(() => {
fetchAssets().then(setAssets).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { fetchLocations().then(d => setLocations(d.locations.filter(l => l.active))).catch(() => {}) }, [])
async function save() {
if (!form || !form.name.trim() || !form.location_id) { setError('Name and location required'); return }
try {
const body = {
name: form.name.trim(),
location_id: form.location_id,
make_model: form.make_model || null,
serial_no: form.serial_no || null,
install_date: form.install_date || null,
notes: form.notes || null,
active: form.active,
}
if (form.id) await updateAsset(form.id, body)
else await createAsset(body)
setForm(null)
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Asset register</h1>
{canManage && (
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
<Plus size={14} strokeWidth={1.75} /> Add asset
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Asset</th><th>Location</th><th>Make / model</th><th>Open tasks</th><th>Recurring</th></tr>
</thead>
<tbody>
{assets.map(a => (
<tr key={a.id} className="clickable" onClick={() => fetchAsset(a.id).then(setDetail).catch(err => setError(err.message))}>
<td>{a.name}</td>
<td>{a.location_name}</td>
<td>{a.make_model || '—'}</td>
<td>{a.open_tasks || 0}</td>
<td>{a.recurring_count || 0}</td>
</tr>
))}
{assets.length === 0 && <tr><td colSpan={5} className="empty-state">No assets yet add the boiler, lifts, fridges</td></tr>}
</tbody>
</table>
</div>
{/* Asset detail modal */}
{detail && (
<div className="modal-overlay" onClick={() => setDetail(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{detail.name}</h2>
<button className="modal-close" onClick={() => setDetail(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="task-card-meta" style={{ marginBottom: 10 }}>
<span>{detail.location_name}</span>
{detail.make_model && <span>{detail.make_model}</span>}
{detail.serial_no && <span>SN {detail.serial_no}</span>}
{detail.install_date && <span>Installed {formatDate(detail.install_date)}</span>}
</div>
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
{detail.templates.length > 0 && (
<>
<div className="section-title">Recurring service tasks</div>
{detail.templates.map(tp => (
<div key={tp.id} className="task-card-meta" style={{ marginBottom: 4 }}>
<span>{tp.title}</span>
<span>every {tp.interval_value} {tp.interval_unit}</span>
<span>next due {formatDate(tp.next_due)}</span>
{!tp.active && <span className="badge badge-outline">paused</span>}
</div>
))}
</>
)}
<div className="section-title">Task history</div>
{detail.tasks.length === 0 && <div className="muted">No tasks logged against this asset.</div>}
{detail.tasks.map(t => (
<div key={t.id} className="timeline-item clickable" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
<div className="timeline-body">
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={t.status} /> <PriorityBadge priority={t.priority} /> {t.title}
</div>
<div className="timeline-meta">
{formatDate(t.created_at)}{t.completed_at ? `${formatDate(t.completed_at)} by ${t.completed_by_name}` : ''}
</div>
</div>
</div>
))}
{canManage && (
<div className="modal-actions">
<button className="btn" onClick={() => {
setForm({
id: detail.id, name: detail.name, location_id: detail.location_id,
make_model: detail.make_model || '', serial_no: detail.serial_no || '',
install_date: detail.install_date?.slice(0, 10) || '', notes: detail.notes || '',
active: detail.active,
})
setDetail(null)
}}>Edit</button>
</div>
)}
</div>
</div>
)}
{/* Asset create/edit modal */}
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit asset' : 'Add asset'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field"><label>Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="e.g. Kitchen walk-in fridge" autoFocus />
</div>
<div className="field"><label>Location</label>
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '' })}>
<option value="">Select location</option>
{locations.map(l => <option key={l.id} value={l.id}>{l.name} ({l.category_name})</option>)}
</select>
</div>
<div className="field-row">
<div className="field"><label>Make / model</label>
<input type="text" value={form.make_model} onChange={e => setForm({ ...form, make_model: e.target.value })} />
</div>
<div className="field"><label>Serial no</label>
<input type="text" value={form.serial_no} onChange={e => setForm({ ...form, serial_no: e.target.value })} />
</div>
</div>
<div className="field"><label>Install date</label>
<input type="date" value={form.install_date} onChange={e => setForm({ ...form, install_date: e.target.value })} />
</div>
<div className="field"><label>Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
{form.id && (
<label className="field-check">
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
Active
</label>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save}>Save</button>
</div>
</div>
</div>
)}
{openTask !== null && <TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />}
</div>
)
}

View file

@ -0,0 +1,238 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, X, FileText, Trash2, Upload } from 'lucide-react'
import type { Contractor, ContractorDetail } from '../types'
import { can } from '../types'
import {
fetchContractors, fetchContractor, createContractor, updateContractor,
uploadContractorDoc, deleteContractorDoc, photoUrl,
} from '../api'
import { useAuth } from '../components/AuthGate'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
interface ContractorForm {
id?: number
name: string
company: string
phone: string
email: string
address: string
notes: string
active: boolean
}
const EMPTY: ContractorForm = { name: '', company: '', phone: '', email: '', address: '', notes: '', active: true }
export default function Contractors() {
const { user } = useAuth()
const [contractors, setContractors] = useState<Contractor[]>([])
const [detail, setDetail] = useState<ContractorDetail | null>(null)
const [form, setForm] = useState<ContractorForm | null>(null)
const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(null)
const [docType, setDocType] = useState('')
const [docExpiry, setDocExpiry] = useState('')
const canManage = can(user, 'manage_contractors')
const load = useCallback(() => {
fetchContractors().then(setContractors).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
const reloadDetail = (id: number) => fetchContractor(id).then(setDetail).catch(err => setError(err.message))
async function save() {
if (!form || !form.name.trim()) { setError('Name required'); return }
try {
const body = {
name: form.name.trim(), company: form.company || null, phone: form.phone || null,
email: form.email || null, address: form.address || null, notes: form.notes || null, active: form.active,
}
if (form.id) await updateContractor(form.id, body)
else await createContractor(body)
setForm(null)
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
const expiringSoon = (d: string | null | undefined) =>
d && new Date(d).getTime() < Date.now() + 30 * 86400000
return (
<div className="page">
<div className="page-header">
<h1>Contractors</h1>
{canManage && (
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
<Plus size={14} strokeWidth={1.75} /> Add contractor
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Name</th><th>Company</th><th>Phone</th><th>Open tasks</th><th>Docs</th></tr>
</thead>
<tbody>
{contractors.map(c => (
<tr key={c.id} className="clickable" onClick={() => reloadDetail(c.id)}>
<td>{c.name}</td>
<td>{c.company || '—'}</td>
<td>{c.phone || '—'}</td>
<td>{c.open_tasks || 0}</td>
<td>
{c.doc_count || 0}
{expiringSoon(c.earliest_doc_expiry) && <span className="overdue"> · doc expiring</span>}
</td>
</tr>
))}
{contractors.length === 0 && <tr><td colSpan={5} className="empty-state">No contractors yet.</td></tr>}
</tbody>
</table>
</div>
{/* Detail modal */}
{detail && (
<div className="modal-overlay" onClick={() => setDetail(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{detail.name}{detail.company ? `${detail.company}` : ''}</h2>
<button className="modal-close" onClick={() => setDetail(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="task-card-meta" style={{ marginBottom: 10 }}>
{detail.phone && <span>{detail.phone}</span>}
{detail.email && <span>{detail.email}</span>}
{detail.address && <span>{detail.address}</span>}
</div>
{detail.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{detail.notes}</p>}
<div className="section-title">Documents</div>
{detail.docs.length === 0 && <div className="muted">No documents uploaded.</div>}
{detail.docs.map(d => (
<div key={d.id} className="timeline-item">
<span className="timeline-icon"><FileText size={14} strokeWidth={1.75} /></span>
<div className="timeline-body">
<a href={photoUrl(d.file_path)} target="_blank" rel="noreferrer">{d.doc_type || d.file_name}</a>
<div className="timeline-meta">
{d.expiry_date
? <span className={expiringSoon(d.expiry_date) ? 'overdue' : ''}>expires {formatDate(d.expiry_date)}</span>
: 'no expiry'}
{' · '}uploaded {formatDate(d.uploaded_at)}
</div>
</div>
{canManage && (
<button className="btn btn-sm" onClick={() => deleteContractorDoc(d.id).then(() => reloadDetail(detail.id))}>
<Trash2 size={13} strokeWidth={1.75} />
</button>
)}
</div>
))}
{canManage && (
<div className="card" style={{ marginTop: 8 }}>
<div className="field-row">
<div className="field"><label>Document type</label>
<input type="text" value={docType} onChange={e => setDocType(e.target.value)} placeholder="e.g. Liability insurance" />
</div>
<div className="field"><label>Expiry (optional)</label>
<input type="date" value={docExpiry} onChange={e => setDocExpiry(e.target.value)} />
</div>
</div>
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
<Upload size={13} strokeWidth={1.75} /> Upload document
<input
type="file" accept="image/*,application/pdf" style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0]
if (f) uploadContractorDoc(detail.id, f, docType, docExpiry)
.then(() => { setDocType(''); setDocExpiry(''); reloadDetail(detail.id); load() })
.catch(err => setError(err.message))
}}
/>
</label>
</div>
)}
<div className="section-title">Recent tasks</div>
{detail.tasks.length === 0 && <div className="muted">No tasks allocated yet.</div>}
{detail.tasks.map(t => (
<div key={t.id} className="timeline-item" style={{ cursor: 'pointer' }} onClick={() => setOpenTask(t.id)}>
<div className="timeline-body">
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={t.status} /> <PriorityBadge priority={t.priority} /> {t.title}
</div>
<div className="timeline-meta">{t.location_name} · {formatDate(t.created_at)}</div>
</div>
</div>
))}
{canManage && (
<div className="modal-actions">
<button className="btn" onClick={() => {
setForm({
id: detail.id, name: detail.name, company: detail.company || '', phone: detail.phone || '',
email: detail.email || '', address: detail.address || '', notes: detail.notes || '', active: detail.active,
})
setDetail(null)
}}>Edit</button>
</div>
)}
</div>
</div>
)}
{/* Create/edit modal */}
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit contractor' : 'Add contractor'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field-row">
<div className="field"><label>Contact name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} autoFocus />
</div>
<div className="field"><label>Company</label>
<input type="text" value={form.company} onChange={e => setForm({ ...form, company: e.target.value })} />
</div>
</div>
<div className="field-row">
<div className="field"><label>Phone</label>
<input type="text" value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} />
</div>
<div className="field"><label>Email</label>
<input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
</div>
</div>
<div className="field"><label>Address</label>
<input type="text" value={form.address} onChange={e => setForm({ ...form, address: e.target.value })} />
</div>
<div className="field"><label>Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
{form.id && (
<label className="field-check">
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
Active
</label>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save}>Save</button>
</div>
</div>
</div>
)}
{openTask !== null && <TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={() => detail && reloadDetail(detail.id)} />}
</div>
)
}

View file

@ -0,0 +1,128 @@
import { useCallback, useEffect, useState } from 'react'
import { Search, Download } from 'lucide-react'
import type { Task, Category } from '../types'
import { can } from '../types'
import { fetchHistory, fetchLocations, historyExportUrl, type HistoryFilters } from '../api'
import { useAuth } from '../components/AuthGate'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, formatDate } from '../components/shared'
export default function HistoryPage() {
const { user } = useAuth()
const [tasks, setTasks] = useState<Task[]>([])
const [totals, setTotals] = useState<{ count: number; total_cost: string | null; avg_days_to_fix: string | null } | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [error, setError] = useState<string | null>(null)
const [openTask, setOpenTask] = useState<number | null>(null)
const [q, setQ] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
const [includeTemporary, setIncludeTemporary] = useState(false)
const filters: HistoryFilters = {
q: q || undefined,
from: from || undefined,
to: to || undefined,
category_id: categoryFilter ?? undefined,
include_temporary: includeTemporary,
}
const load = useCallback(() => {
fetchHistory(filters)
.then(d => { setTasks(d.tasks); setTotals(d.totals); setError(null) })
.catch(err => setError(err.message))
}, [q, from, to, categoryFilter, includeTemporary])
useEffect(() => {
const t = setTimeout(load, q ? 300 : 0) // debounce typing
return () => clearTimeout(t)
}, [load])
useEffect(() => { fetchLocations().then(d => setCategories(d.categories)).catch(() => {}) }, [])
const showCosts = can(user, 'costs')
return (
<div className="page">
<div className="page-header">
<h1>History</h1>
<a className="btn" href={historyExportUrl(filters)}>
<Download size={14} strokeWidth={1.75} /> CSV
</a>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="field-row" style={{ flexWrap: 'wrap' }}>
<div className="field" style={{ flex: 2, minWidth: 200 }}>
<label><Search size={11} strokeWidth={1.75} /> Search</label>
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="Title, description or location…" />
</div>
<div className="field"><label>From</label><input type="date" value={from} onChange={e => setFrom(e.target.value)} /></div>
<div className="field"><label>To</label><input type="date" value={to} onChange={e => setTo(e.target.value)} /></div>
</div>
<div className="chip-bar">
{categories.map(c => (
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
{c.name}
</button>
))}
<button className={`chip ${includeTemporary ? 'active' : ''}`} onClick={() => setIncludeTemporary(!includeTemporary)}>
Include temporary fixes
</button>
</div>
{totals && (
<div className="stats-strip">
<div className="stat-box"><div className="stat-value">{totals.count}</div><div className="stat-label">Fixed</div></div>
{showCosts && totals.total_cost != null && (
<div className="stat-box"><div className="stat-value">£{totals.total_cost}</div><div className="stat-label">Total cost</div></div>
)}
{totals.avg_days_to_fix != null && (
<div className="stat-box"><div className="stat-value">{totals.avg_days_to_fix}</div><div className="stat-label">Avg days to fix</div></div>
)}
</div>
)}
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Task</th>
<th>Location</th>
<th>Priority</th>
<th>Status</th>
<th>Reported</th>
<th>Completed</th>
<th>By</th>
{showCosts && <th>Cost</th>}
</tr>
</thead>
<tbody>
{tasks.map(t => (
<tr key={t.id} className="clickable" onClick={() => setOpenTask(t.id)}>
<td>{t.title}</td>
<td>{t.location_name}</td>
<td><PriorityBadge priority={t.priority} /></td>
<td><StatusBadge status={t.status} /></td>
<td>{formatDate(t.created_at)}</td>
<td>{formatDate(t.completed_at)}</td>
<td>{t.completed_by_name || '—'}</td>
{showCosts && <td>{t.cost != null ? `£${t.cost}` : '—'}</td>}
</tr>
))}
{tasks.length === 0 && (
<tr><td colSpan={showCosts ? 8 : 7} className="empty-state">No fixed tasks match.</td></tr>
)}
</tbody>
</table>
</div>
{openTask !== null && (
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
)}
</div>
)
}

View file

@ -0,0 +1,190 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, RefreshCw, X } from 'lucide-react'
import type { Category, Location } from '../types'
import {
fetchLocations, createLocation, updateLocation, syncNewbookRooms,
createCategory, updateCategory, deleteCategory,
} from '../api'
export default function Locations() {
const [categories, setCategories] = useState<Category[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [syncing, setSyncing] = useState(false)
const [newName, setNewName] = useState('')
const [newCategoryId, setNewCategoryId] = useState<number | ''>('')
const [newCatName, setNewCatName] = useState('')
const [editLoc, setEditLoc] = useState<Location | null>(null)
const load = useCallback(() => {
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
async function addLocation() {
if (!newName.trim() || !newCategoryId) { setError('Location name and category required'); return }
try {
await createLocation({ name: newName.trim(), category_id: newCategoryId as number })
setNewName('')
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed')
}
}
async function addCategory() {
if (!newCatName.trim()) return
try {
await createCategory({ name: newCatName.trim(), sort_order: categories.length + 1 })
setNewCatName('')
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed')
}
}
async function doSync() {
setSyncing(true)
setError(null)
try {
const r = await syncNewbookRooms()
setInfo(`NewBook sync: ${r.created} added, ${r.updated} updated (${r.total} rooms)`)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Sync failed')
} finally {
setSyncing(false)
}
}
return (
<div className="page">
<div className="page-header">
<h1>Locations</h1>
<button className="btn" onClick={doSync} disabled={syncing}>
<RefreshCw size={14} strokeWidth={1.75} /> {syncing ? 'Syncing…' : 'Sync NewBook rooms'}
</button>
</div>
{error && <div className="error-banner">{error}</div>}
{info && <div className="card" style={{ background: 'var(--ok-bg)' }}>{info}</div>}
{/* Add manual location */}
<div className="card">
<div className="field-row" style={{ alignItems: 'flex-end' }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>New location</label>
<input type="text" value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. Main kitchen, Bar, Terrace" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Category</label>
<select value={newCategoryId} onChange={e => setNewCategoryId(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">Select</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<button className="btn btn-primary" onClick={addLocation} style={{ marginBottom: 1 }}>
<Plus size={14} strokeWidth={1.75} /> Add
</button>
</div>
</div>
{categories.map(c => {
const locs = locations.filter(l => l.category_id === c.id)
return (
<div key={c.id}>
<div className="section-title">
{c.name} {c.is_rooms && <span className="badge badge-outline">NewBook rooms</span>} ({locs.filter(l => l.active).length})
</div>
<div className="chip-bar">
{locs.map(l => (
<button
key={l.id}
className="chip"
style={l.active ? undefined : { opacity: .45, textDecoration: 'line-through' }}
title={l.source === 'newbook' ? `NewBook site ${l.newbook_site_id}` : 'Manual location — click to edit'}
onClick={() => setEditLoc(l)}
>
{l.name}
</button>
))}
{locs.length === 0 && <span className="muted" style={{ fontSize: 12.5 }}>none</span>}
{c.is_rooms && locs.length === 0 && <span className="muted" style={{ fontSize: 12.5 }}> run the NewBook sync</span>}
</div>
</div>
)
})}
{/* Category management */}
<div className="section-title">Categories</div>
<div className="card">
{categories.map(c => (
<div key={c.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<span style={{ flex: 1 }}>{c.name}{c.is_rooms ? ' (rooms)' : ''}</span>
<button className="btn btn-sm" onClick={() => {
const name = window.prompt('Rename category', c.name)
if (name && name !== c.name) updateCategory(c.id, { name }).then(load).catch(err => setError(err.message))
}}>Rename</button>
{!c.is_rooms && (
<button className="btn btn-sm" onClick={() => {
if (window.confirm(`Delete category "${c.name}"? Only possible when it has no locations.`)) {
deleteCategory(c.id).then(load).catch(err => setError(err.message))
}
}}>Delete</button>
)}
</div>
))}
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 10 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>New category</label>
<input type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)} placeholder="e.g. Plant Rooms" />
</div>
<button className="btn" onClick={addCategory} style={{ marginBottom: 1 }}>
<Plus size={14} strokeWidth={1.75} /> Add
</button>
</div>
</div>
{/* Edit location modal */}
{editLoc && (
<div className="modal-overlay" onClick={() => setEditLoc(null)}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">
<h2>{editLoc.name}</h2>
<button className="modal-close" onClick={() => setEditLoc(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
{editLoc.source === 'newbook' ? (
<p className="muted">NewBook room (site {editLoc.newbook_site_id}) name and status come from the sync.</p>
) : (
<>
<div className="field"><label>Name</label>
<input type="text" value={editLoc.name} onChange={e => setEditLoc({ ...editLoc, name: e.target.value })} />
</div>
<div className="field"><label>Category</label>
<select value={editLoc.category_id} onChange={e => setEditLoc({ ...editLoc, category_id: parseInt(e.target.value) })}>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</>
)}
<label className="field-check">
<input type="checkbox" checked={editLoc.active} onChange={e => setEditLoc({ ...editLoc, active: e.target.checked })} />
Active
</label>
<div className="modal-actions">
<button className="btn" onClick={() => setEditLoc(null)}>Cancel</button>
<button className="btn btn-primary" onClick={() => {
updateLocation(editLoc.id, {
name: editLoc.name, category_id: editLoc.category_id, active: editLoc.active,
}).then(() => { setEditLoc(null); load() }).catch(err => setError(err.message))
}}>Save</button>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,230 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X, Play } from 'lucide-react'
import type { Template, Category, Location, Asset, Priority } from '../types'
import { PRIORITIES, PRIORITY_LABELS, can } from '../types'
import { fetchTemplates, createTemplate, updateTemplate, runDueTemplates, fetchLocations, fetchAssets } from '../api'
import { useAuth } from '../components/AuthGate'
import AssigneeSelect, { type Assignment } from '../components/AssigneeSelect'
import { PriorityBadge, formatDate } from '../components/shared'
interface TemplateForm {
id?: number
title: string
description: string
location_id: number | ''
asset_id: number | ''
priority: Priority
unusable: boolean
interval_value: number
interval_unit: 'days' | 'weeks' | 'months'
next_due: string
template_notes: string
active: boolean
assignment: Assignment
}
const EMPTY: TemplateForm = {
title: '', description: '', location_id: '', asset_id: '', priority: 'medium', unusable: false,
interval_value: 1, interval_unit: 'months', next_due: '', template_notes: '', active: true,
assignment: { assigned_type: 'staff', assigned_to: null, assigned_to_name: null, contractor_id: null },
}
export default function Recurring() {
const { user } = useAuth()
const [templates, setTemplates] = useState<Template[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [form, setForm] = useState<TemplateForm | null>(null)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const canManage = can(user, 'manage_templates')
const load = useCallback(() => {
fetchTemplates().then(setTemplates).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations.filter(l => l.active)) }).catch(() => {})
fetchAssets().then(setAssets).catch(() => {})
}, [])
const grouped = useMemo(() => categories.map(c => ({
category: c,
locations: locations.filter(l => l.category_id === c.id),
})).filter(g => g.locations.length), [categories, locations])
const formAssets = useMemo(
() => assets.filter(a => form && a.location_id === form.location_id),
[assets, form?.location_id]
)
async function save() {
if (!form || !form.title.trim() || !form.location_id || !form.next_due) {
setError('Title, location and first due date are required')
return
}
try {
const body = {
title: form.title.trim(),
description: form.description || null,
location_id: form.location_id,
asset_id: form.asset_id || null,
priority: form.priority,
unusable: form.unusable,
interval_value: form.interval_value,
interval_unit: form.interval_unit,
next_due: form.next_due,
template_notes: form.template_notes,
active: form.active,
assigned_type: form.assignment.assigned_type,
assigned_to: form.assignment.assigned_to,
assigned_to_name: form.assignment.assigned_to_name,
contractor_id: form.assignment.contractor_id,
}
if (form.id) await updateTemplate(form.id, body)
else await createTemplate(body)
setForm(null)
setError(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Recurring tasks</h1>
{canManage && (
<>
<button className="btn" onClick={() => runDueTemplates().then(r => { setInfo(`Spawned ${r.spawned} due task(s)`); load() }).catch(err => setError(err.message))}>
<Play size={14} strokeWidth={1.75} /> Run due now
</button>
<button className="btn btn-primary" onClick={() => setForm(EMPTY)}>
<Plus size={14} strokeWidth={1.75} /> New template
</button>
</>
)}
</div>
{error && <div className="error-banner">{error}</div>}
{info && <div className="card" style={{ background: 'var(--ok-bg)' }}>{info}</div>}
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Task</th><th>Location</th><th>Asset</th><th>Priority</th><th>Every</th><th>Next due</th><th>Allocated</th><th></th></tr>
</thead>
<tbody>
{templates.map(t => (
<tr key={t.id} className={canManage ? 'clickable' : ''} onClick={() => canManage && setForm({
id: t.id, title: t.title, description: t.description || '',
location_id: t.location_id, asset_id: t.asset_id || '', priority: t.priority,
unusable: t.unusable, interval_value: t.interval_value, interval_unit: t.interval_unit,
next_due: t.next_due.slice(0, 10), template_notes: t.template_notes, active: t.active,
assignment: {
assigned_type: t.assigned_type, assigned_to: t.assigned_to,
assigned_to_name: t.assigned_to_name, contractor_id: t.contractor_id,
},
})}>
<td>{t.title}</td>
<td>{t.location_name}</td>
<td>{t.asset_name || '—'}</td>
<td><PriorityBadge priority={t.priority} /></td>
<td>{t.interval_value} {t.interval_unit}</td>
<td className={new Date(t.next_due) <= new Date() ? 'overdue' : ''}>{formatDate(t.next_due)}</td>
<td>{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}</td>
<td>{!t.active && <span className="badge badge-outline">paused</span>}</td>
</tr>
))}
{templates.length === 0 && (
<tr><td colSpan={8} className="empty-state">No recurring templates set up fire alarm tests, boiler service, legionella flushing</td></tr>
)}
</tbody>
</table>
</div>
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit template' : 'New recurring template'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field"><label>Title</label>
<input type="text" value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="e.g. Fire alarm weekly test" autoFocus />
</div>
<div className="field"><label>Location</label>
<select value={form.location_id} onChange={e => setForm({ ...form, location_id: e.target.value ? parseInt(e.target.value) : '', asset_id: '' })}>
<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>
{formAssets.length > 0 && (
<div className="field"><label>Asset (optional)</label>
<select value={form.asset_id} onChange={e => setForm({ ...form, asset_id: e.target.value ? parseInt(e.target.value) : '' })}>
<option value="">None</option>
{formAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
)}
<div className="field"><label>Description</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
<div className="field"><label>Priority</label>
<div className="chip-bar" style={{ marginBottom: 0 }}>
{PRIORITIES.map(p => (
<button key={p} type="button" className={`chip ${form.priority === p ? 'active' : ''}`} onClick={() => setForm({ ...form, priority: p })}>
{PRIORITY_LABELS[p]}
</button>
))}
</div>
</div>
<div className="field-row">
<div className="field"><label>Repeat every</label>
<input type="number" min={1} value={form.interval_value} onChange={e => setForm({ ...form, interval_value: parseInt(e.target.value) || 1 })} />
</div>
<div className="field"><label>Unit</label>
<select value={form.interval_unit} onChange={e => setForm({ ...form, interval_unit: e.target.value as TemplateForm['interval_unit'] })}>
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
</select>
</div>
<div className="field"><label>{form.id ? 'Next due' : 'First due'}</label>
<input type="date" value={form.next_due} onChange={e => setForm({ ...form, next_due: e.target.value })} />
</div>
</div>
<AssigneeSelect value={form.assignment} onChange={a => setForm({ ...form, assignment: a })} />
<div className="field">
<label>Template notes (shown on every occurrence)</label>
<textarea value={form.template_notes} onChange={e => setForm({ ...form, template_notes: e.target.value })} placeholder="Accumulated tips from previous visits…" />
<div className="field-hint">Notes added from a spawned task with add to template ticked land here automatically.</div>
</div>
{form.id && (
<label className="field-check">
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
Active (untick to pause the schedule)
</label>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save}>Save</button>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { Save } from 'lucide-react'
import type { AppConfig, AuthUser, Contractor } from '../types'
import { fetchConfig, updateConfig, fetchAssignableUsers, fetchContractors } from '../api'
export default function Settings() {
const [config, setConfig] = useState<AppConfig | null>(null)
const [users, setUsers] = useState<AuthUser[]>([])
const [contractors, setContractors] = useState<Contractor[]>([])
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
useEffect(() => {
fetchConfig().then(setConfig).catch(err => setError(err.message))
fetchAssignableUsers().then(setUsers).catch(() => {})
fetchContractors().then(setContractors).catch(() => {})
}, [])
if (!config) return <div className="page">{error ? <div className="error-banner">{error}</div> : 'Loading…'}</div>
async function saveAll() {
if (!config) return
setError(null)
setSaved(false)
try {
for (const [key, value] of Object.entries(config)) {
await updateConfig(key, value)
}
setSaved(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
}
}
const set = (patch: Partial<AppConfig>) => { setConfig({ ...config, ...patch }); setSaved(false) }
return (
<div className="page" style={{ maxWidth: 640 }}>
<div className="page-header">
<h1>Settings</h1>
<button className="btn btn-primary" onClick={saveAll}>
<Save size={14} strokeWidth={1.75} /> Save
</button>
</div>
{error && <div className="error-banner">{error}</div>}
{saved && <div className="card" style={{ background: 'var(--ok-bg)' }}>Settings saved.</div>}
<div className="section-title">Default allocation</div>
<div className="card">
<div className="field">
<label>Default allocation type</label>
<select value={config.default_assigned_type} onChange={e => set({ default_assigned_type: e.target.value as AppConfig['default_assigned_type'] })}>
<option value="staff">Staff</option>
<option value="contractor">Contractor</option>
</select>
</div>
{config.default_assigned_type === 'staff' ? (
<div className="field">
<label>Default staff member</label>
<select
value={config.default_assignee}
onChange={e => {
const u = users.find(x => x.email === e.target.value)
set({ default_assignee: e.target.value, default_assignee_name: u?.name || '' })
}}
>
<option value="">Unassigned</option>
{users.map(u => <option key={u.email} value={u.email}>{u.name}</option>)}
</select>
<div className="field-hint">New tasks are allocated here unless the reporter picks someone else.</div>
</div>
) : (
<div className="field">
<label>Default contractor</label>
<select
value={config.default_contractor_id ?? ''}
onChange={e => set({ default_contractor_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">None</option>
{contractors.map(c => <option key={c.id} value={c.id}>{c.name}{c.company ? `${c.company}` : ''}</option>)}
</select>
</div>
)}
</div>
<div className="section-title">Notifications</div>
<div className="card">
<label className="field-check" style={{ marginBottom: 10 }}>
<input type="checkbox" checked={config.notify_on_assign} onChange={e => set({ notify_on_assign: e.target.checked })} />
Email the assignee when a task is allocated or reallocated
</label>
<label className="field-check" style={{ marginBottom: 10 }}>
<input type="checkbox" checked={config.notify_on_urgent} onChange={e => set({ notify_on_urgent: e.target.checked })} />
Email when an urgent task is logged
</label>
<div className="field">
<label>Urgent notification address</label>
<input type="email" value={config.urgent_notify_email} onChange={e => set({ urgent_notify_email: e.target.value })} placeholder="maintenance@…" />
</div>
<div className="field-hint">Uses the stack SMTP settings (Settings app integrations).</div>
</div>
<div className="section-title">NewBook room blocking</div>
<div className="card">
<div className="field-row">
<div className="field">
<label>Status set when blocking a room</label>
<input type="text" value={config.newbook_block_status} onChange={e => set({ newbook_block_status: e.target.value })} />
</div>
<div className="field">
<label>Status set when releasing a room</label>
<input type="text" value={config.newbook_unblock_status} onChange={e => set({ newbook_unblock_status: e.target.value })} />
</div>
</div>
<div className="field-hint">
Must match NewBook site status values exactly (e.g. Maintenance, Dirty). Blocking is always an explicit
per-task confirmation never automatic.
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, RefreshCw, Camera, BedDouble } from 'lucide-react'
import type { Task, Category, Location, AppConfig, TaskStatus } from '../types'
import { STATUS_LABELS, can } from '../types'
import { fetchTasks, fetchLocations, fetchConfig } from '../api'
import { useAuth } from '../components/AuthGate'
import NewTaskModal from '../components/NewTaskModal'
import TaskModal from '../components/TaskModal'
import { PriorityBadge, StatusBadge, UnusableBadge, ageLabel, formatDate } from '../components/shared'
const OPEN_STATUSES: TaskStatus[] = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix']
export default function Summary() {
const { user } = useAuth()
const [tasks, setTasks] = useState<Task[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [config, setConfig] = useState<AppConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
const [mineOnly, setMineOnly] = useState(false)
const [unoccupiedOnly, setUnoccupiedOnly] = useState(false)
const [showNew, setShowNew] = useState(false)
const [openTask, setOpenTask] = useState<number | null>(null)
const load = useCallback(() => {
setLoading(true)
fetchTasks({
status: statusFilter ?? OPEN_STATUSES.join(','),
category_id: categoryFilter ?? undefined,
assigned_to: mineOnly ? user.email : undefined,
unoccupied: unoccupiedOnly,
})
.then(t => { setTasks(t); setError(null) })
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [statusFilter, categoryFilter, mineOnly, unoccupiedOnly, user.email])
useEffect(() => { load() }, [load])
useEffect(() => {
fetchLocations().then(d => { setCategories(d.categories); setLocations(d.locations) }).catch(() => {})
fetchConfig().then(setConfig).catch(() => {})
}, [])
const counts = useMemo(() => {
const c: Partial<Record<TaskStatus, number>> = {}
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1
return c
}, [tasks])
const roomsCategoryExists = categories.some(c => c.is_rooms)
return (
<div className="page">
<div className="page-header">
<h1>Open maintenance</h1>
<button className="btn" onClick={load} disabled={loading}>
<RefreshCw size={14} strokeWidth={1.75} /> Refresh
</button>
{can(user, 'report') && (
<button className="btn btn-primary" onClick={() => setShowNew(true)}>
<Plus size={14} strokeWidth={1.75} /> Report fault
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="chip-bar">
<button className={`chip ${statusFilter === null ? 'active' : ''}`} onClick={() => setStatusFilter(null)}>
All open
</button>
{OPEN_STATUSES.map(s => (
<button key={s} className={`chip ${statusFilter === s ? 'active' : ''}`} onClick={() => setStatusFilter(statusFilter === s ? null : s)}>
{STATUS_LABELS[s]}{counts[s] ? ` (${counts[s]})` : ''}
</button>
))}
</div>
<div className="chip-bar">
{categories.map(c => (
<button key={c.id} className={`chip ${categoryFilter === c.id ? 'active' : ''}`} onClick={() => setCategoryFilter(categoryFilter === c.id ? null : c.id)}>
{c.name}
</button>
))}
<button className={`chip ${mineOnly ? 'active' : ''}`} onClick={() => setMineOnly(!mineOnly)}>
Mine
</button>
{roomsCategoryExists && (
<button
className={`chip ${unoccupiedOnly ? 'active' : ''}`}
title="Only rooms with no in-house guest right now (live from NewBook)"
onClick={() => setUnoccupiedOnly(!unoccupiedOnly)}
>
<BedDouble size={13} strokeWidth={1.75} /> Unoccupied rooms only
</button>
)}
</div>
{tasks.length === 0 && !loading && (
<div className="empty-state">No open tasks match these filters.</div>
)}
{tasks.map(t => (
<div
key={t.id}
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
onClick={() => setOpenTask(t.id)}
>
<div className="task-card-main">
<div className="task-card-title">
{t.title}
{t.unusable && <UnusableBadge />}
{t.newbook_blocked && <span className="badge badge-outline">NB blocked</span>}
{t.template_id && <span className="badge badge-outline">Recurring</span>}
</div>
<div className="task-card-meta">
<span>{t.location_name}</span>
<span>{t.category_name}</span>
{t.asset_name && <span>{t.asset_name}</span>}
<span>{ageLabel(t.created_at)} old</span>
{t.due_date && (
<span className={new Date(t.due_date) < new Date() ? 'overdue' : ''}>
due {formatDate(t.due_date)}
</span>
)}
{t.hold_until && <span>held until {formatDate(t.hold_until)}</span>}
{t.photo_count > 0 && <span><Camera size={12} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> {t.photo_count}</span>}
</div>
</div>
<div className="task-card-side">
<PriorityBadge priority={t.priority} />
<StatusBadge status={t.status} />
<span className="muted" style={{ fontSize: 11.5 }}>
{t.assigned_type === 'contractor' ? (t.contractor_name || '—') : (t.assigned_to_name || 'Unassigned')}
</span>
</div>
</div>
))}
{showNew && (
<NewTaskModal
categories={categories}
locations={locations}
config={config}
onClose={() => setShowNew(false)}
onCreated={load}
/>
)}
{openTask !== null && (
<TaskModal taskId={openTask} onClose={() => setOpenTask(null)} onChanged={load} />
)}
</div>
)
}

236
frontend/src/types.ts Normal file
View file

@ -0,0 +1,236 @@
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
export type TaskStatus =
| 'submitted'
| 'in_progress'
| 'hold_parts'
| 'hold_scheduled'
| 'temporary_fix'
| 'fixed'
export type AssignedType = 'staff' | 'contractor'
export type PhotoStage = 'report' | 'progress' | 'resolution'
export const PRIORITIES: Priority[] = ['low', 'medium', 'high', 'urgent']
export const PRIORITY_LABELS: Record<Priority, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
urgent: 'Urgent',
}
export const STATUS_LABELS: Record<TaskStatus, string> = {
submitted: 'Submitted',
in_progress: 'In Progress',
hold_parts: 'Hold — Parts Ordered',
hold_scheduled: 'Hold — Later Date',
temporary_fix: 'Temporary Fix',
fixed: 'Fixed',
}
// Legal transitions mirrored from the backend (task-core.js) so the UI only
// offers valid moves; resolve statuses go through the resolve dialog.
export const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
submitted: ['in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
in_progress: ['submitted', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed'],
hold_parts: ['submitted', 'in_progress', 'hold_scheduled', 'temporary_fix', 'fixed'],
hold_scheduled: ['submitted', 'in_progress', 'hold_parts', 'temporary_fix', 'fixed'],
temporary_fix: ['submitted', 'in_progress', 'fixed'],
fixed: ['submitted'],
}
export interface Category {
id: number
name: string
sort_order: number
is_rooms: boolean
}
export interface Location {
id: number
name: string
category_id: number
category_name: string
is_rooms: boolean
source: 'manual' | 'newbook'
newbook_site_id: string | null
active: boolean
sort_order: number
}
export interface Task {
id: number
title: string
description: string | null
location_id: number
location_name: string
location_source: 'manual' | 'newbook'
newbook_site_id: string | null
category_id: number
category_name: string
is_rooms: boolean
asset_id: number | null
asset_name: string | null
template_id: number | null
priority: Priority
status: TaskStatus
unusable: boolean
newbook_blocked: boolean
hold_until: string | null
due_date: string | null
assigned_type: AssignedType
assigned_to: string | null
assigned_to_name: string | null
contractor_id: number | null
contractor_name: string | null
contractor_company: string | null
created_by: string | null
created_by_name: string | null
completed_by: string | null
completed_by_name: string | null
completed_at: string | null
cost?: string | null
cost_notes?: string | null
created_at: string
updated_at: string
photo_count: number
days_to_fix?: number
}
export interface TaskPhoto {
id: number
task_id: number
file_name: string
file_path: string
mime_type: string
stage: PhotoStage
uploaded_by: string
uploaded_at: string
}
export interface TaskEvent {
id: number
task_id: number
event_type: string
from_status: TaskStatus | null
to_status: TaskStatus | null
note: string | null
user_name: string | null
created_at: string
}
export interface TaskDetail extends Task {
photos: TaskPhoto[]
events: TaskEvent[]
template: {
id: number
title: string
interval_value: number
interval_unit: string
next_due: string
active: boolean
} | null
}
export interface Asset {
id: number
name: string
location_id: number
location_name: string
category_name?: string
make_model: string | null
serial_no: string | null
install_date: string | null
notes: string | null
active: boolean
open_tasks?: number
recurring_count?: number
}
export interface AssetDetail extends Asset {
tasks: Array<Pick<Task, 'id' | 'title' | 'status' | 'priority' | 'created_at' | 'completed_at' | 'completed_by_name'>>
templates: Array<Pick<Template, 'id' | 'title' | 'interval_value' | 'interval_unit' | 'next_due' | 'active'>>
}
export interface Contractor {
id: number
name: string
company: string | null
phone: string | null
email: string | null
address: string | null
notes: string | null
active: boolean
open_tasks?: number
doc_count?: number
earliest_doc_expiry?: string | null
}
export interface ContractorDoc {
id: number
contractor_id: number
file_name: string
file_path: string
mime_type: string
doc_type: string | null
expiry_date: string | null
uploaded_at: string
}
export interface ContractorDetail extends Contractor {
docs: ContractorDoc[]
tasks: Array<Pick<Task, 'id' | 'title' | 'status' | 'priority' | 'created_at' | 'completed_at' | 'location_name'>>
}
export interface Template {
id: number
title: string
description: string | null
location_id: number
location_name: string
asset_id: number | null
asset_name: string | null
priority: Priority
unusable: boolean
assigned_type: AssignedType
assigned_to: string | null
assigned_to_name: string | null
contractor_id: number | null
contractor_name: string | null
interval_value: number
interval_unit: 'days' | 'weeks' | 'months'
next_due: string
template_notes: string
active: boolean
}
export interface AppConfig {
default_assigned_type: AssignedType
default_assignee: string
default_assignee_name: string
default_contractor_id: number | null
urgent_notify_email: string
notify_on_assign: boolean
notify_on_urgent: boolean
newbook_block_status: string
newbook_unblock_status: string
}
export interface AuthUser {
id: number
email: string
name: string
}
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=maintenance strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/maintenance/',
plugins: [react()],
})