From 6ca395097e1754c868982b5184f0ee6fc1017bf3 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 3 Jul 2026 21:28:57 +0000 Subject: [PATCH] =?UTF-8?q?Maintenance=20log=20book=20app=20=E2=80=94=20in?= =?UTF-8?q?itial=20scaffold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + backend/Dockerfile | 8 + backend/package.json | 19 + backend/src/auth.js | 57 + backend/src/db.js | 195 ++ backend/src/index.js | 54 + backend/src/ip-check.js | 80 + backend/src/lib/mailer.js | 79 + backend/src/lib/newbook.js | 71 + backend/src/lib/scheduler.js | 67 + backend/src/lib/task-core.js | 80 + backend/src/routes/assets.js | 75 + backend/src/routes/config.js | 26 + backend/src/routes/contractors.js | 126 ++ backend/src/routes/history.js | 102 ++ backend/src/routes/locations.js | 123 ++ backend/src/routes/photos.js | 70 + backend/src/routes/tasks.js | 315 ++++ backend/src/routes/templates.js | 95 + docker-compose.yml | 42 + frontend/Dockerfile | 13 + frontend/index.html | 16 + frontend/nginx.conf | 40 + frontend/package-lock.json | 1901 ++++++++++++++++++++ frontend/package.json | 24 + frontend/src/App.tsx | 32 + frontend/src/api.ts | 218 +++ frontend/src/components/AssigneeSelect.tsx | 75 + frontend/src/components/AuthGate.tsx | 51 + frontend/src/components/Layout.tsx | 57 + frontend/src/components/NewTaskModal.tsx | 204 +++ frontend/src/components/TaskModal.tsx | 379 ++++ frontend/src/components/shared.tsx | 44 + frontend/src/index.css | 377 ++++ frontend/src/main.tsx | 10 + frontend/src/pages/Assets.tsx | 206 +++ frontend/src/pages/Contractors.tsx | 238 +++ frontend/src/pages/History.tsx | 128 ++ frontend/src/pages/Locations.tsx | 190 ++ frontend/src/pages/Recurring.tsx | 230 +++ frontend/src/pages/Settings.tsx | 123 ++ frontend/src/pages/Summary.tsx | 159 ++ frontend/src/types.ts | 236 +++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 19 + frontend/vite.config.ts | 7 + seed-app.js | 60 + 47 files changed, 6727 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/auth.js create mode 100644 backend/src/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/ip-check.js create mode 100644 backend/src/lib/mailer.js create mode 100644 backend/src/lib/newbook.js create mode 100644 backend/src/lib/scheduler.js create mode 100644 backend/src/lib/task-core.js create mode 100644 backend/src/routes/assets.js create mode 100644 backend/src/routes/config.js create mode 100644 backend/src/routes/contractors.js create mode 100644 backend/src/routes/history.js create mode 100644 backend/src/routes/locations.js create mode 100644 backend/src/routes/photos.js create mode 100644 backend/src/routes/tasks.js create mode 100644 backend/src/routes/templates.js create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AssigneeSelect.tsx create mode 100644 frontend/src/components/AuthGate.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/NewTaskModal.tsx create mode 100644 frontend/src/components/TaskModal.tsx create mode 100644 frontend/src/components/shared.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Assets.tsx create mode 100644 frontend/src/pages/Contractors.tsx create mode 100644 frontend/src/pages/History.tsx create mode 100644 frontend/src/pages/Locations.tsx create mode 100644 frontend/src/pages/Recurring.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/Summary.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 seed-app.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c878b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +uploads/ +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b4cc893 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5f6a6c1 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..9f4d09d --- /dev/null +++ b/backend/src/auth.js @@ -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}` }) + } + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..bd4ffa9 --- /dev/null +++ b/backend/src/db.js @@ -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])) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..fb072b2 --- /dev/null +++ b/backend/src/index.js @@ -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) +} diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 0000000..4d8cb19 --- /dev/null +++ b/backend/src/ip-check.js @@ -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 +} diff --git a/backend/src/lib/mailer.js b/backend/src/lib/mailer.js new file mode 100644 index 0000000..bdc8e28 --- /dev/null +++ b/backend/src/lib/mailer.js @@ -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" `, + 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.' : ''}` + ) +} diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js new file mode 100644 index 0000000..1e82edf --- /dev/null +++ b/backend/src/lib/newbook.js @@ -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 }) +} diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js new file mode 100644 index 0000000..40a382a --- /dev/null +++ b/backend/src/lib/scheduler.js @@ -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) +} diff --git a/backend/src/lib/task-core.js b/backend/src/lib/task-core.js new file mode 100644 index 0000000..a0efd20 --- /dev/null +++ b/backend/src/lib/task-core.js @@ -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 +} diff --git a/backend/src/routes/assets.js b/backend/src/routes/assets.js new file mode 100644 index 0000000..5eb63c1 --- /dev/null +++ b/backend/src/routes/assets.js @@ -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] + }) +} diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js new file mode 100644 index 0000000..9bf80ea --- /dev/null +++ b/backend/src/routes/config.js @@ -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 } + }) +} diff --git a/backend/src/routes/contractors.js b/backend/src/routes/contractors.js new file mode 100644 index 0000000..ef95566 --- /dev/null +++ b/backend/src/routes/contractors.js @@ -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 } + }) +} diff --git a/backend/src/routes/history.js b/backend/src/routes/history.js new file mode 100644 index 0000000..25e64ba --- /dev/null +++ b/backend/src/routes/history.js @@ -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') + }) +} diff --git a/backend/src/routes/locations.js b/backend/src/routes/locations.js new file mode 100644 index 0000000..0885fbd --- /dev/null +++ b/backend/src/routes/locations.js @@ -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 } + }) +} diff --git a/backend/src/routes/photos.js b/backend/src/routes/photos.js new file mode 100644 index 0000000..9466858 --- /dev/null +++ b/backend/src/routes/photos.js @@ -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 } + }) +} diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js new file mode 100644 index 0000000..a1d9cd8 --- /dev/null +++ b/backend/src/routes/tasks.js @@ -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}` }) + } + }) +} diff --git a/backend/src/routes/templates.js b/backend/src/routes/templates.js new file mode 100644 index 0000000..fe232cf --- /dev/null +++ b/backend/src/routes/templates.js @@ -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 } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f122c5d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..84af198 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dd06a7d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Maintenance + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..6b3d944 --- /dev/null +++ b/frontend/nginx.conf @@ -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/; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5df6207 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1901 @@ +{ + "name": "hnf-maintenance-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hnf-maintenance-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.385", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", + "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4e71fbb --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..20deaf0 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..81df2a1 --- /dev/null +++ b/frontend/src/api.ts @@ -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(path: string, opts: RequestInit = {}): Promise { + 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 { + return request('/locations', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateLocation(id: number, body: Partial): Promise { + 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 { + return request('/categories', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateCategory(id: number, body: Partial): Promise { + 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 { + 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 { + return request(`/tasks/${id}`) +} +export function createTask(body: Record): Promise { + return request('/tasks', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateTask(id: number, body: Record): Promise { + 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 { + 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 { + 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 { + return request(`/assets${includeInactive ? '?include_inactive=true' : ''}`) +} +export function fetchAsset(id: number): Promise { + return request(`/assets/${id}`) +} +export function createAsset(body: Record): Promise { + return request('/assets', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateAsset(id: number, body: Record): Promise { + return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} + +// Contractors +export function fetchContractors(includeInactive = false): Promise { + return request(`/contractors${includeInactive ? '?include_inactive=true' : ''}`) +} +export function fetchContractor(id: number): Promise { + return request(`/contractors/${id}`) +} +export function createContractor(body: Record): Promise { + return request('/contractors', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateContractor(id: number, body: Record): Promise { + return request(`/contractors/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export async function uploadContractorDoc(contractorId: number, file: File, docType: string, expiryDate: string): Promise { + 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 { + return request('/templates') +} +export function createTemplate(body: Record): Promise