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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
uploads/
*.log

8
backend/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
RUN mkdir -p /app/uploads
EXPOSE 3001
CMD ["node", "src/index.js"]

19
backend/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "hnf-maintenance-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"nodemailer": "^6.9.16",
"pg": "^8.13.1"
}
}

57
backend/src/auth.js Normal file
View file

@ -0,0 +1,57 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'maintenance'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
// Legacy token — grant basic caps until re-login
caps = ['view', 'report']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

195
backend/src/db.js Normal file
View file

@ -0,0 +1,195 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Location categories: Rooms, Kitchen, Public Areas, External, Garden, ...
-- is_rooms marks the category whose locations sync from NewBook and
-- participate in the occupancy filter / out-of-order push.
CREATE TABLE IF NOT EXISTS location_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
sort_order INT NOT NULL DEFAULT 0,
is_rooms BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS locations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category_id INT NOT NULL REFERENCES location_categories(id),
source TEXT NOT NULL DEFAULT 'manual', -- manual | newbook
newbook_site_id TEXT UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS assets (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
location_id INT NOT NULL REFERENCES locations(id),
make_model TEXT,
serial_no TEXT,
install_date DATE,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS contractors (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
company TEXT,
phone TEXT,
email TEXT,
address TEXT,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS contractor_docs (
id SERIAL PRIMARY KEY,
contractor_id INT NOT NULL REFERENCES contractors(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
file_size INT,
doc_type TEXT, -- e.g. Liability insurance, Gas Safe cert
expiry_date DATE,
uploaded_by TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Recurring task templates. Scheduler spawns a task when next_due arrives.
CREATE TABLE IF NOT EXISTS task_templates (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
location_id INT NOT NULL REFERENCES locations(id),
asset_id INT REFERENCES assets(id),
priority TEXT NOT NULL DEFAULT 'medium',
unusable BOOLEAN NOT NULL DEFAULT FALSE,
assigned_type TEXT NOT NULL DEFAULT 'staff', -- staff | contractor
assigned_to TEXT,
assigned_to_name TEXT,
contractor_id INT REFERENCES contractors(id),
interval_value INT NOT NULL DEFAULT 1,
interval_unit TEXT NOT NULL DEFAULT 'months', -- days | weeks | months
next_due DATE NOT NULL,
template_notes TEXT NOT NULL DEFAULT '', -- carried onto every future occurrence
active BOOLEAN NOT NULL DEFAULT TRUE,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
location_id INT NOT NULL REFERENCES locations(id),
asset_id INT REFERENCES assets(id),
template_id INT REFERENCES task_templates(id),
priority TEXT NOT NULL DEFAULT 'medium', -- low | medium | high | urgent
status TEXT NOT NULL DEFAULT 'submitted',
unusable BOOLEAN NOT NULL DEFAULT FALSE,
newbook_blocked BOOLEAN NOT NULL DEFAULT FALSE,
hold_until DATE,
due_date DATE,
assigned_type TEXT NOT NULL DEFAULT 'staff', -- staff | contractor
assigned_to TEXT,
assigned_to_name TEXT,
contractor_id INT REFERENCES contractors(id),
created_by TEXT,
created_by_name TEXT,
completed_by TEXT,
completed_by_name TEXT,
completed_at TIMESTAMPTZ,
cost NUMERIC(10,2),
cost_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS tasks_status_idx ON tasks (status);
CREATE INDEX IF NOT EXISTS tasks_location_idx ON tasks (location_id);
CREATE INDEX IF NOT EXISTS tasks_assigned_idx ON tasks (assigned_to);
CREATE INDEX IF NOT EXISTS tasks_completed_idx ON tasks (completed_at DESC);
CREATE TABLE IF NOT EXISTS task_photos (
id SERIAL PRIMARY KEY,
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
file_size INT,
stage TEXT NOT NULL DEFAULT 'report', -- report | progress | resolution
uploaded_by TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Audit trail + comment thread per task.
CREATE TABLE IF NOT EXISTS task_events (
id SERIAL PRIMARY KEY,
task_id INT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, -- created | status_change | reassigned | comment | photo | cost | newbook_block | newbook_unblock | reopened | edited
from_status TEXT,
to_status TEXT,
note TEXT,
user_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS task_events_task_idx ON task_events (task_id, created_at);
`)
await seedDefaults()
}
async function seedDefaults() {
const categories = [
{ name: 'Rooms', sort: 1, is_rooms: true },
{ name: 'Kitchen', sort: 2, is_rooms: false },
{ name: 'Public Areas', sort: 3, is_rooms: false },
{ name: 'External', sort: 4, is_rooms: false },
{ name: 'Garden', sort: 5, is_rooms: false },
]
for (const c of categories) {
await pool.query(
`INSERT INTO location_categories (name, sort_order, is_rooms)
VALUES ($1, $2, $3) ON CONFLICT (name) DO NOTHING`,
[c.name, c.sort, c.is_rooms]
)
}
const defaults = {
default_assigned_type: 'staff',
default_assignee: '', // staff email
default_assignee_name: '',
default_contractor_id: null,
urgent_notify_email: '',
notify_on_assign: true,
notify_on_urgent: true,
newbook_block_status: 'Maintenance',
newbook_unblock_status: 'Dirty',
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(
`INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, JSON.stringify(value)]
)
}
}
export async function getConfig() {
const { rows } = await pool.query('SELECT key, value FROM config')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}

54
backend/src/index.js Normal file
View file

@ -0,0 +1,54 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import multipart from '@fastify/multipart'
import staticFiles from '@fastify/static'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { initDb } from './db.js'
import { startScheduler } from './lib/scheduler.js'
import { locationRoutes } from './routes/locations.js'
import { taskRoutes } from './routes/tasks.js'
import { photoRoutes } from './routes/photos.js'
import { historyRoutes } from './routes/history.js'
import { assetRoutes } from './routes/assets.js'
import { contractorRoutes } from './routes/contractors.js'
import { templateRoutes } from './routes/templates.js'
import { configRoutes } from './routes/config.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
const app = Fastify({ logger: true, trustProxy: true })
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } })
await app.register(staticFiles, {
root: UPLOADS_DIR,
prefix: '/api/uploads/',
decorateReply: false,
})
app.get('/health', async () => ({ status: 'healthy' }))
await app.register(locationRoutes)
await app.register(taskRoutes)
await app.register(photoRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(historyRoutes)
await app.register(assetRoutes)
await app.register(contractorRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(templateRoutes)
await app.register(configRoutes)
try {
await initDb()
startScheduler(app)
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

79
backend/src/lib/mailer.js Normal file
View file

@ -0,0 +1,79 @@
import nodemailer from 'nodemailer'
const SETTINGS_URL = process.env.SETTINGS_URL || ''
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _smtpCache = null // { config, expires_at }
let _transporter = null
async function getSmtpConfig() {
if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, {
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`)
const config = await res.json()
if (!config.host) throw new Error('SMTP not configured in settings')
_smtpCache = { config, expires_at: Date.now() + 5 * 60_000 }
_transporter = null
return config
}
async function getTransporter() {
if (_transporter) return _transporter
const config = await getSmtpConfig()
const port = parseInt(config.port || '587')
_transporter = nodemailer.createTransport({
host: config.host,
port,
secure: port === 465,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
})
return _transporter
}
const HOTEL_NAME = process.env.VITE_HOTEL_NAME || 'Hotel'
// Fire-and-forget: email failure must never block a task write.
async function send(to, subject, text) {
if (!to) return
try {
const config = await getSmtpConfig()
const transport = await getTransporter()
await transport.sendMail({
from: config.from || `"${HOTEL_NAME} Maintenance" <noreply@localhost>`,
to,
subject,
text,
})
} catch (err) {
console.error(`Maintenance mail to ${to} failed: ${err.message}`)
}
}
function taskSummary(task, locationName) {
const lines = [
`Task: ${task.title}`,
`Location: ${locationName}`,
`Priority: ${task.priority}`,
]
if (task.description) lines.push('', task.description)
return lines.join('\n')
}
export function notifyAssignment(task, locationName, toEmail) {
return send(
toEmail,
`[Maintenance] Assigned to you: ${task.title}`,
`A maintenance task has been assigned to you.\n\n${taskSummary(task, locationName)}`
)
}
export function notifyUrgent(task, locationName, toEmail) {
return send(
toEmail,
`[Maintenance] URGENT: ${task.title}`,
`An urgent maintenance task has been logged.\n\n${taskSummary(task, locationName)}${task.unusable ? '\n\nLocation flagged as UNUSABLE.' : ''}`
)
}

View file

@ -0,0 +1,71 @@
const API_BASE = 'https://api.newbook.cloud/rest/'
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching NewBook credentials`)
const s = await res.json()
return {
username: s.username || '',
password: s.password || '',
apiKey: s.api_key || '',
region: s.region || 'eu',
}
}
async function callApi(endpoint, data = {}) {
const creds = await getCredentials()
if (!creds.username || !creds.password || !creds.apiKey) {
throw new Error('NewBook API credentials not configured')
}
const locationId = process.env.NEWBOOK_LOCATION_ID
const body = { ...data, region: creds.region, api_key: creds.apiKey }
if (locationId) body.location_id = locationId
const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 30000)
try {
const res = await fetch(API_BASE + endpoint, {
method: 'POST',
signal: ctrl.signal,
headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` },
body: JSON.stringify(body),
})
clearTimeout(timer)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`NewBook API ${res.status}: ${text.slice(0, 200)}`)
}
return await res.json()
} catch (err) {
clearTimeout(timer)
throw err
}
}
// Fetch all sites (rooms): site_id, site_name, site_status, site_category_id, site_category_name.
export async function fetchSites() {
const res = await callApi('sites_list', {})
return res?.data ?? []
}
// Fetch bookings spanning a date range (list_type 'all' includes arrived/confirmed/etc).
export async function fetchBookings(fromDate, toDate) {
const res = await callApi('bookings_list', {
period_from: `${fromDate} 00:00:00`,
period_to: `${toDate} 23:59:59`,
list_type: 'all',
})
return res?.data ?? []
}
// Update room status in NewBook. NewBook expects 'status' parameter (not 'site_status').
export async function updateSiteStatus(siteId, status) {
return callApi('sites_update', { site_id: siteId, status })
}

View file

@ -0,0 +1,67 @@
import { pool } from '../db.js'
import { createTask, logEvent } from './task-core.js'
const CHECK_INTERVAL_MS = 60 * 60 * 1000 // hourly
function addInterval(date, value, unit) {
const d = new Date(date)
if (unit === 'days') d.setDate(d.getDate() + value)
else if (unit === 'weeks') d.setDate(d.getDate() + value * 7)
else d.setMonth(d.getMonth() + value)
return d
}
function isoDate(d) {
return d.toISOString().slice(0, 10)
}
// Spawn one task per due template, then advance next_due past today so a
// backlog after downtime produces a single task, not one per missed occurrence.
export async function spawnDueTemplates(log = console) {
const today = isoDate(new Date())
const { rows: due } = await pool.query(
`SELECT * FROM task_templates WHERE active = TRUE AND next_due <= $1 ORDER BY id`,
[today]
)
for (const tpl of due) {
try {
const task = await createTask({
title: tpl.title,
description: tpl.description,
location_id: tpl.location_id,
asset_id: tpl.asset_id,
template_id: tpl.id,
priority: tpl.priority,
unusable: tpl.unusable,
due_date: tpl.next_due,
assigned_type: tpl.assigned_type,
assigned_to: tpl.assigned_to,
assigned_to_name: tpl.assigned_to_name,
contractor_id: tpl.contractor_id,
}, { email: null, name: 'Scheduler' })
// Notes accumulated from previous occurrences appear in the task thread.
if (tpl.template_notes) {
await logEvent(task.id, 'comment', {
note: `Notes from previous occurrences:\n${tpl.template_notes}`,
userName: 'Scheduler',
})
}
let next = addInterval(tpl.next_due, tpl.interval_value, tpl.interval_unit)
while (isoDate(next) <= today) next = addInterval(next, tpl.interval_value, tpl.interval_unit)
await pool.query('UPDATE task_templates SET next_due = $1 WHERE id = $2', [isoDate(next), tpl.id])
log.info?.(`Scheduler spawned task ${task.id} from template ${tpl.id} (${tpl.title})`)
} catch (err) {
log.error?.(`Scheduler failed for template ${tpl.id}: ${err.message}`)
}
}
return due.length
}
export function startScheduler(app) {
spawnDueTemplates(app.log).catch(err => app.log.error(err))
setInterval(() => spawnDueTemplates(app.log).catch(err => app.log.error(err)), CHECK_INTERVAL_MS)
}

View file

@ -0,0 +1,80 @@
import { pool, getConfig } from '../db.js'
import { notifyAssignment, notifyUrgent } from './mailer.js'
export const PRIORITIES = ['low', 'medium', 'high', 'urgent']
export const STATUSES = ['submitted', 'in_progress', 'hold_parts', 'hold_scheduled', 'temporary_fix', 'fixed']
// Legal state transitions. temporary_fix still counts as open.
export const TRANSITIONS = {
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'], // reopen only
}
export async function logEvent(taskId, eventType, { fromStatus = null, toStatus = null, note = null, userName = null } = {}) {
await pool.query(
`INSERT INTO task_events (task_id, event_type, from_status, to_status, note, user_name)
VALUES ($1, $2, $3, $4, $5, $6)`,
[taskId, eventType, fromStatus, toStatus, note, userName]
)
}
// Shared by the tasks route and the recurring-template scheduler.
// input.assigned_* fall back to the configured defaults when absent.
export async function createTask(input, actor) {
const config = await getConfig()
let assignedType = input.assigned_type || config.default_assigned_type || 'staff'
let assignedTo = input.assigned_to ?? null
let assignedToName = input.assigned_to_name ?? null
let contractorId = input.contractor_id ?? null
if (!input.assigned_type && assignedTo == null && contractorId == null) {
if (assignedType === 'contractor' && config.default_contractor_id) {
contractorId = config.default_contractor_id
} else if (config.default_assignee) {
assignedType = 'staff'
assignedTo = config.default_assignee
assignedToName = config.default_assignee_name || config.default_assignee
}
}
if (assignedType === 'contractor') { assignedTo = null; assignedToName = null }
else contractorId = null
const { rows } = await pool.query(
`INSERT INTO tasks (title, description, location_id, asset_id, template_id, priority, unusable,
due_date, assigned_type, assigned_to, assigned_to_name, contractor_id,
created_by, created_by_name)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[
input.title, input.description || null, input.location_id, input.asset_id || null,
input.template_id || null, input.priority || 'medium', input.unusable === true,
input.due_date || null, assignedType, assignedTo, assignedToName, contractorId,
actor.email, actor.name,
]
)
const task = rows[0]
await logEvent(task.id, 'created', { toStatus: 'submitted', userName: actor.name })
const { rows: locRows } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
const locationName = locRows[0]?.name || `#${task.location_id}`
// Notifications are fire-and-forget; mailer swallows errors.
if (config.notify_on_assign) {
if (task.assigned_type === 'staff' && task.assigned_to && task.assigned_to !== actor.email) {
notifyAssignment(task, locationName, task.assigned_to)
} else if (task.assigned_type === 'contractor' && task.contractor_id) {
const { rows: c } = await pool.query('SELECT email FROM contractors WHERE id = $1', [task.contractor_id])
if (c[0]?.email) notifyAssignment(task, locationName, c[0].email)
}
}
if (config.notify_on_urgent && task.priority === 'urgent' && config.urgent_notify_email) {
notifyUrgent(task, locationName, config.urgent_notify_email)
}
return task
}

View file

@ -0,0 +1,75 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function assetRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/assets — register with location names + open task counts
app.get('/api/assets', { preHandler: requireCap('view') }, async (req) => {
const includeInactive = req.query.include_inactive === 'true'
const { rows } = await pool.query(
`SELECT a.*, l.name AS location_name, c.name AS category_name,
(SELECT COUNT(*)::int FROM tasks t WHERE t.asset_id = a.id AND t.status != 'fixed') AS open_tasks,
(SELECT COUNT(*)::int FROM task_templates tp WHERE tp.asset_id = a.id AND tp.active = TRUE) AS recurring_count
FROM assets a
JOIN locations l ON l.id = a.location_id
JOIN location_categories c ON c.id = l.category_id
${includeInactive ? '' : 'WHERE a.active = TRUE'}
ORDER BY l.name, a.name`
)
return rows
})
// GET /api/assets/:id — detail with task history + linked recurring templates
app.get('/api/assets/:id', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT a.*, l.name AS location_name FROM assets a JOIN locations l ON l.id = a.location_id WHERE a.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Asset not found' })
const { rows: tasks } = await pool.query(
`SELECT t.id, t.title, t.status, t.priority, t.created_at, t.completed_at, t.completed_by_name
FROM tasks t WHERE t.asset_id = $1 ORDER BY t.created_at DESC LIMIT 100`,
[req.params.id]
)
const { rows: templates } = await pool.query(
`SELECT id, title, interval_value, interval_unit, next_due, active
FROM task_templates WHERE asset_id = $1 ORDER BY next_due`,
[req.params.id]
)
return { ...rows[0], tasks, templates }
})
app.post('/api/assets', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const b = req.body || {}
if (!b.name || !b.location_id) return reply.status(400).send({ error: 'name and location_id required' })
const { rows } = await pool.query(
`INSERT INTO assets (name, location_id, make_model, serial_no, install_date, notes)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[b.name, b.location_id, b.make_model || null, b.serial_no || null, b.install_date || null, b.notes || null]
)
return rows[0]
})
app.patch('/api/assets/:id', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM assets WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Asset not found' })
const a = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE assets SET name = $1, location_id = $2, make_model = $3, serial_no = $4,
install_date = $5, notes = $6, active = $7 WHERE id = $8 RETURNING *`,
[
b.name ?? a.name, b.location_id ?? a.location_id,
b.make_model !== undefined ? b.make_model : a.make_model,
b.serial_no !== undefined ? b.serial_no : a.serial_no,
b.install_date !== undefined ? b.install_date : a.install_date,
b.notes !== undefined ? b.notes : a.notes,
b.active ?? a.active,
req.params.id,
]
)
return rows[0]
})
}

View file

@ -0,0 +1,26 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function configRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/config — all config keys as a flat object
app.get('/api/config', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/config/:key — update a single config key
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params
const { value } = req.body || {}
if (value === undefined) return reply.status(400).send({ error: 'value required' })
await pool.query(
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, JSON.stringify(value)]
)
return { ok: true }
})
}

View file

@ -0,0 +1,126 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_DOCS = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']
export async function contractorRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/contractors — list (view cap: needed for the allocation selector)
app.get('/api/contractors', { preHandler: requireCap('view') }, async (req) => {
const includeInactive = req.query.include_inactive === 'true'
const { rows } = await pool.query(
`SELECT c.*,
(SELECT COUNT(*)::int FROM tasks t WHERE t.contractor_id = c.id AND t.status != 'fixed') AS open_tasks,
(SELECT COUNT(*)::int FROM contractor_docs d WHERE d.contractor_id = c.id) AS doc_count,
(SELECT MIN(d.expiry_date) FROM contractor_docs d
WHERE d.contractor_id = c.id AND d.expiry_date IS NOT NULL) AS earliest_doc_expiry
FROM contractors c
${includeInactive ? '' : 'WHERE c.active = TRUE'}
ORDER BY c.name`
)
return rows
})
// GET /api/contractors/:id — detail with docs + recent tasks
app.get('/api/contractors/:id', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM contractors WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Contractor not found' })
const { rows: docs } = await pool.query(
'SELECT * FROM contractor_docs WHERE contractor_id = $1 ORDER BY uploaded_at DESC', [req.params.id]
)
const { rows: tasks } = await pool.query(
`SELECT t.id, t.title, t.status, t.priority, t.created_at, t.completed_at, l.name AS location_name
FROM tasks t JOIN locations l ON l.id = t.location_id
WHERE t.contractor_id = $1 ORDER BY t.created_at DESC LIMIT 50`,
[req.params.id]
)
return { ...rows[0], docs, tasks }
})
app.post('/api/contractors', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const b = req.body || {}
if (!b.name) return reply.status(400).send({ error: 'name required' })
const { rows } = await pool.query(
`INSERT INTO contractors (name, company, phone, email, address, notes)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[b.name, b.company || null, b.phone || null, b.email || null, b.address || null, b.notes || null]
)
return rows[0]
})
app.patch('/api/contractors/:id', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM contractors WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Contractor not found' })
const c = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE contractors SET name = $1, company = $2, phone = $3, email = $4, address = $5, notes = $6, active = $7
WHERE id = $8 RETURNING *`,
[
b.name ?? c.name,
b.company !== undefined ? b.company : c.company,
b.phone !== undefined ? b.phone : c.phone,
b.email !== undefined ? b.email : c.email,
b.address !== undefined ? b.address : c.address,
b.notes !== undefined ? b.notes : c.notes,
b.active ?? c.active,
req.params.id,
]
)
return rows[0]
})
// POST /api/contractors/:id/docs — multipart: file + doc_type + optional expiry_date
app.post('/api/contractors/:id/docs', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const contractorId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT id FROM contractors WHERE id = $1', [contractorId])
if (!rows.length) return reply.status(404).send({ error: 'Contractor not found' })
let fileData = null, docType = null, expiryDate = null
for await (const part of req.parts()) {
if (part.type === 'file') {
if (!ALLOWED_DOCS.includes(part.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG, WebP and PDF files are allowed' })
}
const ext = extname(part.filename) || '.bin'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'contractors', String(contractorId))
await mkdir(dir, { recursive: true })
let size = 0
const dest = createWriteStream(join(dir, filename))
for await (const chunk of part.file) { dest.write(chunk); size += chunk.length }
await new Promise(r => dest.end(r))
fileData = { filename: part.filename, mimetype: part.mimetype, savedAs: filename, size }
} else {
const val = String(await part.value || '')
if (part.fieldname === 'doc_type') docType = val || null
if (part.fieldname === 'expiry_date') expiryDate = val || null
}
}
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
const filePath = `/contractors/${contractorId}/${fileData.savedAs}`
const { rows: ins } = await pool.query(
`INSERT INTO contractor_docs (contractor_id, file_name, file_path, mime_type, file_size, doc_type, expiry_date, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[contractorId, fileData.filename, filePath, fileData.mimetype, fileData.size, docType, expiryDate, req.user.email]
)
return ins[0]
})
app.delete('/api/contractor-docs/:id', { preHandler: requireCap('manage_contractors') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM contractor_docs WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {})
await pool.query('DELETE FROM contractor_docs WHERE id = $1', [req.params.id])
return { ok: true }
})
}

View file

@ -0,0 +1,102 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
const HISTORY_SELECT = `
SELECT t.*,
l.name AS location_name,
c.id AS category_id, c.name AS category_name,
a.name AS asset_name,
ct.name AS contractor_name,
(SELECT COUNT(*)::int FROM task_photos p WHERE p.task_id = t.id) AS photo_count,
EXTRACT(EPOCH FROM (t.completed_at - t.created_at)) / 86400.0 AS days_to_fix
FROM tasks t
JOIN locations l ON l.id = t.location_id
JOIN location_categories c ON c.id = l.category_id
LEFT JOIN assets a ON a.id = t.asset_id
LEFT JOIN contractors ct ON ct.id = t.contractor_id
`
function buildWhere(q, push) {
const clauses = []
if (q.include_temporary === 'true') clauses.push(`t.status IN ('fixed', 'temporary_fix')`)
else clauses.push(`t.status = 'fixed'`)
if (q.q) {
const term = push(`%${q.q}%`)
clauses.push(`(t.title ILIKE ${term} OR t.description ILIKE ${term} OR l.name ILIKE ${term})`)
}
if (q.from) clauses.push(`t.completed_at >= ${push(q.from)}`)
if (q.to) clauses.push(`t.completed_at < (${push(q.to)}::date + 1)`)
if (q.category_id) clauses.push(`c.id = ${push(parseInt(q.category_id))}`)
if (q.location_id) clauses.push(`t.location_id = ${push(parseInt(q.location_id))}`)
if (q.asset_id) clauses.push(`t.asset_id = ${push(parseInt(q.asset_id))}`)
return clauses
}
export async function historyRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/history — searchable closed tasks + totals
app.get('/api/history', { preHandler: requireCap('view') }, async (req) => {
const vals = []
const push = v => { vals.push(v); return `$${vals.length}` }
const clauses = buildWhere(req.query, push)
const limit = Math.min(parseInt(req.query.limit) || 100, 500)
const offset = parseInt(req.query.offset) || 0
const sql = `${HISTORY_SELECT} WHERE ${clauses.join(' AND ')}
ORDER BY t.completed_at DESC LIMIT ${limit} OFFSET ${offset}`
let { rows } = await pool.query(sql, vals)
const { rows: totals } = await pool.query(
`SELECT COUNT(*)::int AS count,
COALESCE(SUM(t.cost), 0)::numeric(12,2) AS total_cost,
ROUND(AVG(EXTRACT(EPOCH FROM (t.completed_at - t.created_at)) / 86400.0)::numeric, 1) AS avg_days_to_fix
FROM tasks t
JOIN locations l ON l.id = t.location_id
JOIN location_categories c ON c.id = l.category_id
WHERE ${clauses.join(' AND ')}`,
vals
)
const showCosts = hasCap(req, 'costs')
if (!showCosts) rows = rows.map(({ cost, cost_notes, ...rest }) => rest)
return {
tasks: rows,
totals: {
count: totals[0].count,
total_cost: showCosts ? totals[0].total_cost : null,
avg_days_to_fix: totals[0].avg_days_to_fix,
},
}
})
// GET /api/history/export — CSV of the same filtered set
app.get('/api/history/export', { preHandler: requireCap('view') }, async (req, reply) => {
const vals = []
const push = v => { vals.push(v); return `$${vals.length}` }
const clauses = buildWhere(req.query, push)
const { rows } = await pool.query(
`${HISTORY_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY t.completed_at DESC LIMIT 5000`,
vals
)
const showCosts = hasCap(req, 'costs')
const cols = ['id', 'title', 'location_name', 'category_name', 'asset_name', 'priority', 'status',
'created_by_name', 'created_at', 'completed_by_name', 'completed_at', 'days_to_fix']
if (showCosts) cols.push('cost', 'cost_notes')
const esc = v => {
if (v == null) return ''
const s = String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
const csv = [cols.join(',')]
for (const r of rows) csv.push(cols.map(c => esc(r[c])).join(','))
reply.header('Content-Type', 'text/csv')
reply.header('Content-Disposition', 'attachment; filename="maintenance-history.csv"')
return csv.join('\n')
})
}

View file

@ -0,0 +1,123 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { fetchSites } from '../lib/newbook.js'
export async function locationRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/locations — all locations grouped with categories
app.get('/api/locations', { preHandler: requireCap('view') }, async () => {
const { rows: categories } = await pool.query(
'SELECT * FROM location_categories ORDER BY sort_order, name'
)
const { rows: locations } = await pool.query(
`SELECT l.*, c.name AS category_name, c.is_rooms
FROM locations l JOIN location_categories c ON c.id = l.category_id
ORDER BY c.sort_order, l.sort_order, l.name`
)
return { categories, locations }
})
// POST /api/locations — create manual location
app.post('/api/locations', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { name, category_id, sort_order } = req.body || {}
if (!name || !category_id) return reply.status(400).send({ error: 'name and category_id required' })
const { rows } = await pool.query(
`INSERT INTO locations (name, category_id, source, sort_order)
VALUES ($1, $2, 'manual', $3) RETURNING *`,
[name, category_id, sort_order || 0]
)
return rows[0]
})
// PATCH /api/locations/:id — rename / recategorise / activate / order
app.patch('/api/locations/:id', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { name, category_id, active, sort_order } = req.body || {}
const { rows: existing } = await pool.query('SELECT * FROM locations WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Location not found' })
const loc = existing[0]
const { rows } = await pool.query(
`UPDATE locations SET name = $1, category_id = $2, active = $3, sort_order = $4 WHERE id = $5 RETURNING *`,
[
name ?? loc.name,
category_id ?? loc.category_id,
active ?? loc.active,
sort_order ?? loc.sort_order,
req.params.id,
]
)
return rows[0]
})
// POST /api/locations/sync-newbook — upsert NewBook sites into the rooms category
app.post('/api/locations/sync-newbook', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { rows: cats } = await pool.query(
'SELECT id FROM location_categories WHERE is_rooms = TRUE ORDER BY sort_order LIMIT 1'
)
if (!cats.length) return reply.status(400).send({ error: 'No rooms category configured' })
const roomsCategoryId = cats[0].id
let sites
try {
sites = await fetchSites()
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
let created = 0, updated = 0
for (const site of sites) {
const siteId = String(site.site_id)
const name = site.site_name || `Room ${siteId}`
const res = await pool.query(
`INSERT INTO locations (name, category_id, source, newbook_site_id, sort_order)
VALUES ($1, $2, 'newbook', $3, $4)
ON CONFLICT (newbook_site_id) DO UPDATE SET name = EXCLUDED.name, active = TRUE
RETURNING (xmax = 0) AS inserted`,
[name, roomsCategoryId, siteId, parseInt(site.site_order) || 0]
)
res.rows[0].inserted ? created++ : updated++
}
// Rooms no longer in NewBook are deactivated, not deleted (history keeps its FK).
const siteIds = sites.map(s => String(s.site_id))
if (siteIds.length) {
await pool.query(
`UPDATE locations SET active = FALSE
WHERE source = 'newbook' AND NOT (newbook_site_id = ANY($1))`,
[siteIds]
)
}
return { ok: true, created, updated, total: sites.length }
})
// Category management
app.post('/api/categories', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { name, sort_order, is_rooms } = req.body || {}
if (!name) return reply.status(400).send({ error: 'name required' })
const { rows } = await pool.query(
`INSERT INTO location_categories (name, sort_order, is_rooms) VALUES ($1, $2, $3) RETURNING *`,
[name, sort_order || 0, is_rooms === true]
)
return rows[0]
})
app.patch('/api/categories/:id', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM location_categories WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Category not found' })
const cat = existing[0]
const { name, sort_order, is_rooms } = req.body || {}
const { rows } = await pool.query(
`UPDATE location_categories SET name = $1, sort_order = $2, is_rooms = $3 WHERE id = $4 RETURNING *`,
[name ?? cat.name, sort_order ?? cat.sort_order, is_rooms ?? cat.is_rooms, req.params.id]
)
return rows[0]
})
app.delete('/api/categories/:id', { preHandler: requireCap('manage_locations') }, async (req, reply) => {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM locations WHERE category_id = $1', [req.params.id])
if (rows[0].n > 0) return reply.status(409).send({ error: 'Category has locations — move them first' })
await pool.query('DELETE FROM location_categories WHERE id = $1', [req.params.id])
return { ok: true }
})
}

View file

@ -0,0 +1,70 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { logEvent } from '../lib/task-core.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const STAGES = ['report', 'progress', 'resolution']
export async function photoRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// POST /api/tasks/:id/photos — multipart: file + optional stage field
app.post('/api/tasks/:id/photos', { preHandler: requireCap('report') }, async (req, reply) => {
const taskId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT id FROM tasks WHERE id = $1', [taskId])
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
let fileData = null, stage = 'report'
for await (const part of req.parts()) {
if (part.type === 'file') {
fileData = part
// must consume the file stream inside the loop — save it now
if (!ALLOWED_IMAGES.includes(part.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
}
const ext = extname(part.filename) || '.jpg'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'tasks', String(taskId))
await mkdir(dir, { recursive: true })
let size = 0
const dest = createWriteStream(join(dir, filename))
for await (const chunk of part.file) { dest.write(chunk); size += chunk.length }
await new Promise(r => dest.end(r))
fileData = { filename: part.filename, mimetype: part.mimetype, savedAs: filename, size }
} else {
const val = await part.value
if (part.fieldname === 'stage' && STAGES.includes(String(val))) stage = String(val)
}
}
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
const filePath = `/tasks/${taskId}/${fileData.savedAs}`
const { rows: ins } = await pool.query(
`INSERT INTO task_photos (task_id, file_name, file_path, mime_type, file_size, stage, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[taskId, fileData.filename, filePath, fileData.mimetype, fileData.size, stage, req.user.email]
)
await logEvent(taskId, 'photo', { note: `Photo added (${stage})`, userName: req.user.name })
return ins[0]
})
// DELETE /api/photos/:id — uploader or update cap
app.delete('/api/photos/:id', { preHandler: requireCap('report') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM task_photos WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
const photo = rows[0]
if (photo.uploaded_by !== req.user.email && !hasCap(req, 'update')) {
return reply.status(403).send({ error: 'Can only delete your own photos' })
}
await unlink(join(UPLOADS_DIR, photo.file_path)).catch(() => {})
await pool.query('DELETE FROM task_photos WHERE id = $1', [req.params.id])
return { ok: true }
})
}

315
backend/src/routes/tasks.js Normal file
View file

@ -0,0 +1,315 @@
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/task-core.js'
import { fetchBookings, updateSiteStatus } from '../lib/newbook.js'
import { notifyAssignment } from '../lib/mailer.js'
const PRIORITY_ORDER = `CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END`
const TASK_SELECT = `
SELECT t.*,
l.name AS location_name, l.source AS location_source, l.newbook_site_id,
c.id AS category_id, c.name AS category_name, c.is_rooms,
a.name AS asset_name,
ct.name AS contractor_name, ct.company AS contractor_company,
(SELECT COUNT(*)::int FROM task_photos p WHERE p.task_id = t.id) AS photo_count
FROM tasks t
JOIN locations l ON l.id = t.location_id
JOIN location_categories c ON c.id = l.category_id
LEFT JOIN assets a ON a.id = t.asset_id
LEFT JOIN contractors ct ON ct.id = t.contractor_id
`
function stripCosts(task) {
const { cost, cost_notes, ...rest } = task
return rest
}
async function fetchOccupiedSiteIds() {
const today = new Date().toISOString().slice(0, 10)
const bookings = await fetchBookings(today, today)
const occupied = new Set()
for (const b of bookings) {
if (String(b.booking_status).toLowerCase() === 'arrived' && b.site_id != null) {
occupied.add(String(b.site_id))
}
}
return occupied
}
export async function taskRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/tasks — open tasks with filters
// ?status=csv &category_id= &location_id= &priority= &assigned_to= &q= &unoccupied=true
app.get('/api/tasks', { preHandler: requireCap('view') }, async (req, reply) => {
const q = req.query
const clauses = []
const vals = []
const push = v => { vals.push(v); return `$${vals.length}` }
if (q.status) {
const statuses = String(q.status).split(',').filter(s => STATUSES.includes(s))
if (statuses.length) clauses.push(`t.status = ANY(${push(statuses)})`)
else clauses.push(`t.status != 'fixed'`)
} else {
clauses.push(`t.status != 'fixed'`) // default: open tasks
}
if (q.category_id) clauses.push(`c.id = ${push(parseInt(q.category_id))}`)
if (q.location_id) clauses.push(`t.location_id = ${push(parseInt(q.location_id))}`)
if (q.asset_id) clauses.push(`t.asset_id = ${push(parseInt(q.asset_id))}`)
if (q.priority && PRIORITIES.includes(q.priority)) clauses.push(`t.priority = ${push(q.priority)}`)
if (q.assigned_to) clauses.push(`t.assigned_to = ${push(q.assigned_to)}`)
if (q.contractor_id) clauses.push(`t.contractor_id = ${push(parseInt(q.contractor_id))}`)
if (q.q) {
const term = push(`%${q.q}%`)
clauses.push(`(t.title ILIKE ${term} OR t.description ILIKE ${term})`)
}
const sql = `${TASK_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY ${PRIORITY_ORDER}, t.created_at ASC`
let { rows } = await pool.query(sql, vals)
// "Unoccupied rooms only": keep only rooms-category tasks whose NewBook room
// has no in-house (arrived) booking right now.
if (q.unoccupied === 'true') {
let occupied
try {
occupied = await fetchOccupiedSiteIds()
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
rows = rows.filter(t => t.is_rooms && t.newbook_site_id && !occupied.has(t.newbook_site_id))
}
if (!hasCap(req, 'costs')) rows = rows.map(stripCosts)
return rows
})
// GET /api/tasks/:id — full detail with photos + event thread
app.get('/api/tasks/:id', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query(`${TASK_SELECT} WHERE t.id = $1`, [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
let task = rows[0]
const { rows: photos } = await pool.query(
'SELECT * FROM task_photos WHERE task_id = $1 ORDER BY uploaded_at', [task.id]
)
const { rows: events } = await pool.query(
'SELECT * FROM task_events WHERE task_id = $1 ORDER BY created_at', [task.id]
)
let template = null
if (task.template_id) {
const { rows: tpl } = await pool.query(
'SELECT id, title, interval_value, interval_unit, next_due, active FROM task_templates WHERE id = $1',
[task.template_id]
)
template = tpl[0] || null
}
if (!hasCap(req, 'costs')) task = stripCosts(task)
return { ...task, photos, events, template }
})
// POST /api/tasks — create
app.post('/api/tasks', { preHandler: requireCap('report') }, async (req, reply) => {
const b = req.body || {}
if (!b.title || !b.location_id) return reply.status(400).send({ error: 'title and location_id required' })
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' })
const { rows: loc } = await pool.query('SELECT id FROM locations WHERE id = $1 AND active = TRUE', [b.location_id])
if (!loc.length) return reply.status(400).send({ error: 'Unknown or inactive location' })
const task = await createTask(b, { email: req.user.email, name: req.user.name })
return task
})
// PATCH /api/tasks/:id — edit fields, change status, reassign
app.patch('/api/tasks/:id', { preHandler: requireCap('update') }, async (req, reply) => {
const b = req.body || {}
const { rows: existing } = await pool.query('SELECT * FROM tasks WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Task not found' })
const task = existing[0]
const userName = req.user.name
// Status transition
if (b.status && b.status !== task.status) {
if (!STATUSES.includes(b.status)) return reply.status(400).send({ error: 'Invalid status' })
if (!TRANSITIONS[task.status]?.includes(b.status)) {
return reply.status(409).send({ error: `Cannot move from ${task.status} to ${b.status}` })
}
// Resolution statuses must go through /resolve so completed-by/cost are captured
if (['temporary_fix', 'fixed'].includes(b.status)) {
return reply.status(400).send({ error: 'Use /resolve to mark temporary_fix or fixed' })
}
const isReopen = ['fixed', 'temporary_fix'].includes(task.status) && b.status === 'submitted'
await logEvent(task.id, isReopen ? 'reopened' : 'status_change', {
fromStatus: task.status, toStatus: b.status, note: b.note || null, userName,
})
if (isReopen) {
await pool.query(
`UPDATE tasks SET completed_by = NULL, completed_by_name = NULL, completed_at = NULL WHERE id = $1`,
[task.id]
)
}
}
// Reassignment
const reassigning = b.assigned_type !== undefined || b.assigned_to !== undefined || b.contractor_id !== undefined
if (reassigning) {
const newType = b.assigned_type || task.assigned_type
let note
if (newType === 'contractor') {
const { rows: c } = await pool.query('SELECT name, company, email FROM contractors WHERE id = $1', [b.contractor_id])
if (!c.length) return reply.status(400).send({ error: 'Unknown contractor' })
note = `Assigned to contractor: ${c[0].name}${c[0].company ? ` (${c[0].company})` : ''}`
const config = await getConfig()
if (config.notify_on_assign && c[0].email) {
const { rows: l } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
notifyAssignment(task, l[0]?.name || '', c[0].email)
}
} else {
note = b.assigned_to ? `Assigned to ${b.assigned_to_name || b.assigned_to}` : 'Unassigned'
const config = await getConfig()
if (config.notify_on_assign && b.assigned_to && b.assigned_to !== task.assigned_to && b.assigned_to !== req.user.email) {
const { rows: l } = await pool.query('SELECT name FROM locations WHERE id = $1', [task.location_id])
notifyAssignment(task, l[0]?.name || '', b.assigned_to)
}
}
await logEvent(task.id, 'reassigned', { note, userName })
}
const newType = b.assigned_type || task.assigned_type
const { rows } = await pool.query(
`UPDATE tasks SET
title = $1, description = $2, location_id = $3, asset_id = $4,
priority = $5, status = $6, unusable = $7, hold_until = $8, due_date = $9,
assigned_type = $10, assigned_to = $11, assigned_to_name = $12, contractor_id = $13,
updated_at = NOW()
WHERE id = $14 RETURNING *`,
[
b.title ?? task.title,
b.description ?? task.description,
b.location_id ?? task.location_id,
b.asset_id !== undefined ? b.asset_id : task.asset_id,
(b.priority && PRIORITIES.includes(b.priority)) ? b.priority : task.priority,
(b.status && TRANSITIONS[task.status]?.includes(b.status) && !['temporary_fix', 'fixed'].includes(b.status)) ? b.status : task.status,
b.unusable ?? task.unusable,
b.hold_until !== undefined ? b.hold_until : task.hold_until,
b.due_date !== undefined ? b.due_date : task.due_date,
newType,
newType === 'contractor' ? null : (b.assigned_to !== undefined ? b.assigned_to : task.assigned_to),
newType === 'contractor' ? null : (b.assigned_to_name !== undefined ? b.assigned_to_name : task.assigned_to_name),
newType === 'contractor' ? (b.contractor_id !== undefined ? b.contractor_id : task.contractor_id) : null,
task.id,
]
)
return rows[0]
})
// POST /api/tasks/:id/resolve — temporary_fix or fixed, with completed-by and cost
app.post('/api/tasks/:id/resolve', { preHandler: requireCap('resolve') }, async (req, reply) => {
const b = req.body || {}
const status = b.status
if (!['temporary_fix', 'fixed'].includes(status)) {
return reply.status(400).send({ error: 'status must be temporary_fix or fixed' })
}
const { rows: existing } = await pool.query('SELECT * FROM tasks WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Task not found' })
const task = existing[0]
if (!TRANSITIONS[task.status]?.includes(status)) {
return reply.status(409).send({ error: `Cannot move from ${task.status} to ${status}` })
}
const completedBy = b.completed_by || req.user.email
const completedByName = b.completed_by_name || (b.completed_by ? b.completed_by : req.user.name)
const cost = hasCap(req, 'costs') && b.cost != null && b.cost !== '' ? b.cost : null
const { rows } = await pool.query(
`UPDATE tasks SET status = $1, completed_by = $2, completed_by_name = $3, completed_at = NOW(),
cost = COALESCE($4, cost), cost_notes = COALESCE($5, cost_notes), updated_at = NOW()
WHERE id = $6 RETURNING *`,
[status, completedBy, completedByName, cost, b.cost_notes || null, task.id]
)
await logEvent(task.id, 'status_change', {
fromStatus: task.status, toStatus: status,
note: b.note || null, userName: req.user.name,
})
if (cost != null) {
await logEvent(task.id, 'cost', { note: `Cost recorded: £${cost}${b.cost_notes ? `${b.cost_notes}` : ''}`, userName: req.user.name })
}
return rows[0]
})
// POST /api/tasks/:id/comments — note on the thread; optionally append to the recurring template
app.post('/api/tasks/:id/comments', { preHandler: requireCap('report') }, async (req, reply) => {
const { note, add_to_template } = req.body || {}
if (!note) return reply.status(400).send({ error: 'note required' })
const { rows: existing } = await pool.query('SELECT id, template_id FROM tasks WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Task not found' })
const task = existing[0]
await logEvent(task.id, 'comment', { note, userName: req.user.name })
let addedToTemplate = false
if (add_to_template === true && task.template_id) {
const stamp = new Date().toISOString().slice(0, 10)
await pool.query(
`UPDATE task_templates
SET template_notes = CASE WHEN template_notes = '' THEN $1 ELSE template_notes || E'\n' || $1 END
WHERE id = $2`,
[`[${stamp} ${req.user.name}] ${note}`, task.template_id]
)
addedToTemplate = true
}
return { ok: true, added_to_template: addedToTemplate }
})
// POST /api/tasks/:id/newbook-block — set the room out of order in NewBook
app.post('/api/tasks/:id/newbook-block', { preHandler: requireCap('update') }, async (req, reply) => {
return toggleNewbookBlock(req, reply, true)
})
// POST /api/tasks/:id/newbook-unblock — release the room in NewBook
app.post('/api/tasks/:id/newbook-unblock', { preHandler: requireCap('update') }, async (req, reply) => {
return toggleNewbookBlock(req, reply, false)
})
async function toggleNewbookBlock(req, reply, block) {
const { rows } = await pool.query(
`SELECT t.*, l.newbook_site_id, l.source FROM tasks t JOIN locations l ON l.id = t.location_id WHERE t.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Task not found' })
const task = rows[0]
if (task.source !== 'newbook' || !task.newbook_site_id) {
return reply.status(400).send({ error: 'Task location is not a NewBook room' })
}
const config = await getConfig()
const status = block ? (config.newbook_block_status || 'Maintenance') : (config.newbook_unblock_status || 'Dirty')
try {
await updateSiteStatus(task.newbook_site_id, status)
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
await pool.query('UPDATE tasks SET newbook_blocked = $1, updated_at = NOW() WHERE id = $2', [block, task.id])
await logEvent(task.id, block ? 'newbook_block' : 'newbook_unblock', {
note: `Room ${block ? 'blocked' : 'released'} in NewBook (status: ${status})`,
userName: req.user.name,
})
return { ok: true, newbook_blocked: block }
}
// GET /api/occupancy — today's in-house NewBook site ids (for the unoccupied filter UI)
app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => {
try {
const occupied = await fetchOccupiedSiteIds()
return { date: new Date().toISOString().slice(0, 10), occupied_site_ids: [...occupied] }
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
})
}

View file

@ -0,0 +1,95 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { PRIORITIES } from '../lib/task-core.js'
import { spawnDueTemplates } from '../lib/scheduler.js'
const INTERVAL_UNITS = ['days', 'weeks', 'months']
export async function templateRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/templates — recurring task templates
app.get('/api/templates', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query(
`SELECT tp.*, l.name AS location_name, a.name AS asset_name, ct.name AS contractor_name
FROM task_templates tp
JOIN locations l ON l.id = tp.location_id
LEFT JOIN assets a ON a.id = tp.asset_id
LEFT JOIN contractors ct ON ct.id = tp.contractor_id
ORDER BY tp.active DESC, tp.next_due`
)
return rows
})
app.post('/api/templates', { preHandler: requireCap('manage_templates') }, async (req, reply) => {
const b = req.body || {}
if (!b.title || !b.location_id || !b.next_due) {
return reply.status(400).send({ error: 'title, location_id and next_due required' })
}
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' })
if (b.interval_unit && !INTERVAL_UNITS.includes(b.interval_unit)) return reply.status(400).send({ error: 'Invalid interval_unit' })
const interval = parseInt(b.interval_value) || 1
if (interval < 1) return reply.status(400).send({ error: 'interval_value must be at least 1' })
const { rows } = await pool.query(
`INSERT INTO task_templates
(title, description, location_id, asset_id, priority, unusable, assigned_type, assigned_to,
assigned_to_name, contractor_id, interval_value, interval_unit, next_due, template_notes, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[
b.title, b.description || null, b.location_id, b.asset_id || null,
b.priority || 'medium', b.unusable === true,
b.assigned_type === 'contractor' ? 'contractor' : 'staff',
b.assigned_type === 'contractor' ? null : (b.assigned_to || null),
b.assigned_type === 'contractor' ? null : (b.assigned_to_name || null),
b.assigned_type === 'contractor' ? (b.contractor_id || null) : null,
interval, b.interval_unit || 'months', b.next_due,
b.template_notes || '', req.user.email,
]
)
return rows[0]
})
app.patch('/api/templates/:id', { preHandler: requireCap('manage_templates') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM task_templates WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Template not found' })
const t = existing[0]
const b = req.body || {}
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'Invalid priority' })
if (b.interval_unit && !INTERVAL_UNITS.includes(b.interval_unit)) return reply.status(400).send({ error: 'Invalid interval_unit' })
const newType = b.assigned_type || t.assigned_type
const { rows } = await pool.query(
`UPDATE task_templates SET
title = $1, description = $2, location_id = $3, asset_id = $4, priority = $5, unusable = $6,
assigned_type = $7, assigned_to = $8, assigned_to_name = $9, contractor_id = $10,
interval_value = $11, interval_unit = $12, next_due = $13, template_notes = $14, active = $15
WHERE id = $16 RETURNING *`,
[
b.title ?? t.title,
b.description !== undefined ? b.description : t.description,
b.location_id ?? t.location_id,
b.asset_id !== undefined ? b.asset_id : t.asset_id,
b.priority ?? t.priority,
b.unusable ?? t.unusable,
newType,
newType === 'contractor' ? null : (b.assigned_to !== undefined ? b.assigned_to : t.assigned_to),
newType === 'contractor' ? null : (b.assigned_to_name !== undefined ? b.assigned_to_name : t.assigned_to_name),
newType === 'contractor' ? (b.contractor_id !== undefined ? b.contractor_id : t.contractor_id) : null,
b.interval_value ? parseInt(b.interval_value) : t.interval_value,
b.interval_unit ?? t.interval_unit,
b.next_due ?? t.next_due,
b.template_notes !== undefined ? b.template_notes : t.template_notes,
b.active ?? t.active,
req.params.id,
]
)
return rows[0]
})
// POST /api/templates/run-due — manually trigger the scheduler sweep
app.post('/api/templates/run-due', { preHandler: requireCap('manage_templates') }, async (req) => {
const spawned = await spawnDueTemplates(req.log)
return { ok: true, spawned }
})
}

42
docker-compose.yml Normal file
View file

@ -0,0 +1,42 @@
services:
backend:
build: ./backend
security_opt:
- apparmor=unconfined
environment:
- DATABASE_URL=${DATABASE_URL}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- SETTINGS_URL=${SETTINGS_URL}
- SETTINGS_SECRET=${SETTINGS_SECRET}
- APP_SLUG=maintenance
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
- NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID:-}
volumes:
- uploads_data:/app/uploads
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
interval: 10s
retries: 5
start_period: 20s
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
security_opt:
- apparmor=unconfined
ports:
- "${FRONTEND_PORT:-3080}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
default:
driver: bridge
volumes:
uploads_data:

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

60
seed-app.js Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env node
// Run from maintenance/ dir: DATABASE_URL=... node seed-app.js
import pg from 'pg'
const { Pool } = pg
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
await pool.query(`
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('maintenance', 'Maintenance', 'Maintenance log book — faults, recurring tasks, assets and contractors', '/maintenance', 'Wrench', '#b45309', 'Operations', '10.10.10.121', 3080)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
base_path = EXCLUDED.base_path,
icon = EXCLUDED.icon,
theme_color = EXCLUDED.theme_color,
category = EXCLUDED.category,
internal_host = EXCLUDED.internal_host,
internal_port = EXCLUDED.internal_port
`)
// Seed capabilities
await pool.query(`
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('view', 'View Tasks', 'View the maintenance log and history', 1),
('report', 'Report Faults', 'Create tasks, add photos and comments', 2),
('update', 'Update Tasks', 'Change task state, reassign, edit details', 3),
('resolve', 'Resolve Tasks', 'Mark tasks temporary fixed or fixed', 4),
('costs', 'View Costs', 'See and enter repair cost values', 5),
('manage_locations', 'Manage Locations', 'Manage locations, categories and NewBook sync', 6),
('manage_assets', 'Manage Assets', 'Create and edit the asset register', 7),
('manage_contractors', 'Manage Contractors', 'Manage contractors and their documents', 8),
('manage_templates', 'Manage Recurring', 'Create and edit recurring task templates', 9),
('settings', 'Settings', 'Configure maintenance app settings', 10)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'maintenance'
ON CONFLICT (app_id, slug) DO NOTHING
`)
// Grant view + report to Staff role if they have no maintenance caps yet
await pool.query(`
INSERT INTO role_capabilities (role_id, cap_id)
SELECT r.id, ac.id
FROM roles r
JOIN app_capabilities ac ON ac.slug IN ('view', 'report')
JOIN apps a ON a.id = ac.app_id AND a.slug = 'maintenance'
WHERE r.name = 'Staff'
AND NOT EXISTS (
SELECT 1 FROM role_capabilities rc2
JOIN app_capabilities ac2 ON rc2.cap_id = ac2.id
JOIN apps a2 ON ac2.app_id = a2.id AND a2.slug = 'maintenance'
WHERE rc2.role_id = r.id
)
ON CONFLICT DO NOTHING
`)
console.log('maintenance app seeded.')
await pool.end()