Maintenance log book app — initial scaffold
Multi-department fault log: NewBook-synced room locations + manual locations with categories, six-state task flow (submitted/in progress/ hold-parts/hold-later/temporary fix/fixed), photos per stage, priorities with unusable flag and per-task NewBook out-of-order push, costs on resolve, comment/audit thread, recurring task templates with note-to-template carryover, asset register, contractor register with document attachments, staff/contractor allocation, occupancy-aware summary filter, searchable history with CSV export, email notifications. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
6ca395097e
47 changed files with 6727 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
uploads/
|
||||
*.log
|
||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal 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
19
backend/package.json
Normal 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
57
backend/src/auth.js
Normal 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
195
backend/src/db.js
Normal 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
54
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
79
backend/src/lib/mailer.js
Normal 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.' : ''}`
|
||||
)
|
||||
}
|
||||
71
backend/src/lib/newbook.js
Normal file
71
backend/src/lib/newbook.js
Normal 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 })
|
||||
}
|
||||
67
backend/src/lib/scheduler.js
Normal file
67
backend/src/lib/scheduler.js
Normal 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)
|
||||
}
|
||||
80
backend/src/lib/task-core.js
Normal file
80
backend/src/lib/task-core.js
Normal 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
|
||||
}
|
||||
75
backend/src/routes/assets.js
Normal file
75
backend/src/routes/assets.js
Normal 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]
|
||||
})
|
||||
}
|
||||
26
backend/src/routes/config.js
Normal file
26
backend/src/routes/config.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
126
backend/src/routes/contractors.js
Normal file
126
backend/src/routes/contractors.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
102
backend/src/routes/history.js
Normal file
102
backend/src/routes/history.js
Normal 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')
|
||||
})
|
||||
}
|
||||
123
backend/src/routes/locations.js
Normal file
123
backend/src/routes/locations.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
70
backend/src/routes/photos.js
Normal file
70
backend/src/routes/photos.js
Normal 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
315
backend/src/routes/tasks.js
Normal 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}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
95
backend/src/routes/templates.js
Normal file
95
backend/src/routes/templates.js
Normal 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
42
docker-compose.yml
Normal 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
13
frontend/Dockerfile
Normal 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
16
frontend/index.html
Normal 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
40
frontend/nginx.conf
Normal 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
1901
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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
32
frontend/src/App.tsx
Normal 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
218
frontend/src/api.ts
Normal 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()
|
||||
}
|
||||
75
frontend/src/components/AssigneeSelect.tsx
Normal file
75
frontend/src/components/AssigneeSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/AuthGate.tsx
Normal file
51
frontend/src/components/AuthGate.tsx
Normal 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>
|
||||
}
|
||||
57
frontend/src/components/Layout.tsx
Normal file
57
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
frontend/src/components/NewTaskModal.tsx
Normal file
204
frontend/src/components/NewTaskModal.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { X, Camera } from 'lucide-react'
|
||||
import type { Category, Location, Task, Priority, AppConfig, Asset } from '../types'
|
||||
import { PRIORITIES, PRIORITY_LABELS } from '../types'
|
||||
import { createTask, fetchTasks, uploadTaskPhoto, blockRoomInNewbook, fetchAssets } from '../api'
|
||||
import AssigneeSelect, { type Assignment } from './AssigneeSelect'
|
||||
import { PriorityBadge, StatusBadge } from './shared'
|
||||
|
||||
export default function NewTaskModal({ categories, locations, config, onClose, onCreated }: {
|
||||
categories: Category[]
|
||||
locations: Location[]
|
||||
config: AppConfig | null
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [locationId, setLocationId] = useState<number | ''>('')
|
||||
const [assetId, setAssetId] = useState<number | ''>('')
|
||||
const [priority, setPriority] = useState<Priority>('medium')
|
||||
const [unusable, setUnusable] = useState(false)
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [existing, setExisting] = useState<Task[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [assignment, setAssignment] = useState<Assignment>({
|
||||
assigned_type: (config?.default_assigned_type as Assignment['assigned_type']) || 'staff',
|
||||
assigned_to: config?.default_assignee || null,
|
||||
assigned_to_name: config?.default_assignee_name || config?.default_assignee || null,
|
||||
contractor_id: config?.default_contractor_id ?? null,
|
||||
})
|
||||
|
||||
const location = useMemo(() => locations.find(l => l.id === locationId), [locations, locationId])
|
||||
const locationAssets = useMemo(
|
||||
() => assets.filter(a => a.location_id === locationId),
|
||||
[assets, locationId]
|
||||
)
|
||||
|
||||
useEffect(() => { fetchAssets().then(setAssets).catch(() => {}) }, [])
|
||||
|
||||
// Duplicate hint: existing open tasks at the chosen location
|
||||
useEffect(() => {
|
||||
if (!locationId) { setExisting([]); return }
|
||||
fetchTasks({ location_id: locationId as number }).then(setExisting).catch(() => setExisting([]))
|
||||
}, [locationId])
|
||||
|
||||
const grouped = useMemo(() => categories.map(c => ({
|
||||
category: c,
|
||||
locations: locations.filter(l => l.category_id === c.id && l.active),
|
||||
})).filter(g => g.locations.length), [categories, locations])
|
||||
|
||||
async function submit() {
|
||||
if (!title.trim() || !locationId) { setError('Title and location are required'); return }
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const task = await createTask({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
location_id: locationId,
|
||||
asset_id: assetId || null,
|
||||
priority,
|
||||
unusable,
|
||||
due_date: dueDate || null,
|
||||
assigned_type: assignment.assigned_type,
|
||||
assigned_to: assignment.assigned_to,
|
||||
assigned_to_name: assignment.assigned_to_name,
|
||||
contractor_id: assignment.contractor_id,
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
await uploadTaskPhoto(task.id, file, 'report').catch(() => {})
|
||||
}
|
||||
|
||||
// Explicit confirm — never block a room in NewBook silently
|
||||
if (unusable && location?.source === 'newbook') {
|
||||
const ok = window.confirm(
|
||||
`Also mark ${location.name} as out of order in NewBook (status: ${config?.newbook_block_status || 'Maintenance'}) so it can't be sold?`
|
||||
)
|
||||
if (ok) await blockRoomInNewbook(task.id).catch(err => window.alert(`NewBook block failed: ${err.message}`))
|
||||
}
|
||||
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create task')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Report a fault</h2>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="field">
|
||||
<label>Title</label>
|
||||
<input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Shower dripping" autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Location</label>
|
||||
<select value={locationId} onChange={e => { setLocationId(e.target.value ? parseInt(e.target.value) : ''); setAssetId('') }}>
|
||||
<option value="">Select location…</option>
|
||||
{grouped.map(g => (
|
||||
<optgroup key={g.category.id} label={g.category.name}>
|
||||
{g.locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{existing.length > 0 && (
|
||||
<div className="card" style={{ background: 'var(--warn-bg)' }}>
|
||||
<strong style={{ fontSize: 12.5 }}>Already open at this location:</strong>
|
||||
{existing.slice(0, 4).map(t => (
|
||||
<div key={t.id} style={{ fontSize: 12.5, marginTop: 4, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<StatusBadge status={t.status} /> {t.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{locationAssets.length > 0 && (
|
||||
<div className="field">
|
||||
<label>Asset (optional)</label>
|
||||
<select value={assetId} onChange={e => setAssetId(e.target.value ? parseInt(e.target.value) : '')}>
|
||||
<option value="">None</option>
|
||||
{locationAssets.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Description (optional)</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="More detail about the fault…" />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Priority</label>
|
||||
<div className="chip-bar" style={{ marginBottom: 0 }}>
|
||||
{PRIORITIES.map(p => (
|
||||
<button key={p} type="button" className={`chip ${priority === p ? 'active' : ''}`} onClick={() => setPriority(p)}>
|
||||
{PRIORITY_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
<PriorityBadge priority={priority} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field-check" style={{ marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={unusable} onChange={e => setUnusable(e.target.checked)} />
|
||||
Makes this location unusable / unsellable
|
||||
</label>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Due date (optional)</label>
|
||||
<input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssigneeSelect value={assignment} onChange={setAssignment} />
|
||||
|
||||
<div className="field">
|
||||
<label>Photos (optional)</label>
|
||||
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
|
||||
<Camera size={14} strokeWidth={1.75} />
|
||||
Add photos
|
||||
<input
|
||||
type="file" accept="image/*" multiple capture="environment" style={{ display: 'none' }}
|
||||
onChange={e => setFiles([...files, ...Array.from(e.target.files || [])])}
|
||||
/>
|
||||
</label>
|
||||
{files.length > 0 && (
|
||||
<div className="field-hint">
|
||||
{files.map((f, i) => (
|
||||
<span key={i} style={{ marginRight: 8 }}>
|
||||
{f.name} <button className="btn btn-sm" style={{ padding: '0 4px' }} onClick={() => setFiles(files.filter((_, j) => j !== i))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
379
frontend/src/components/TaskModal.tsx
Normal file
379
frontend/src/components/TaskModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/shared.tsx
Normal file
44
frontend/src/components/shared.tsx
Normal 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
377
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
206
frontend/src/pages/Assets.tsx
Normal file
206
frontend/src/pages/Assets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
238
frontend/src/pages/Contractors.tsx
Normal file
238
frontend/src/pages/Contractors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/pages/History.tsx
Normal file
128
frontend/src/pages/History.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
frontend/src/pages/Locations.tsx
Normal file
190
frontend/src/pages/Locations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
230
frontend/src/pages/Recurring.tsx
Normal file
230
frontend/src/pages/Recurring.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
frontend/src/pages/Settings.tsx
Normal file
123
frontend/src/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
frontend/src/pages/Summary.tsx
Normal file
159
frontend/src/pages/Summary.tsx
Normal 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
236
frontend/src/types.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal 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
7
frontend/vite.config.ts
Normal 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
60
seed-app.js
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue